From 00830f8714d5c5c2f9d45f15fd941c16dd7fb060 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:29:46 +0000 Subject: [PATCH 01/32] Redesign Select Color panel: index and true color tabs Replace the old index page and the iced_aw ColorPicker popup with one Select Color panel: - Index Color tab: standard colors, ACI 10-249 grid, grays; arrow keys move through the palette. - True Color tab: HSB, RGB and gradient-field modes built on two new widgets (GradientSlider, ColorArea) and iced_aw NumberInput boxes, an editable hex field and a nearest/exact index color button. - Current/New swatch: clicking Current reverts. A mixed selection opens on the color of the most recently drawn object with *VARIES* on top. - Color field accepts an index, a color name, r,g,b or hex. - Enter confirms, Esc cancels, other keys stay in the panel. - New labels translated in all 22 locales. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- Cargo.lock | 12 + Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 3 + locales/bg-BG/opencadstudio.ftl | 3 + locales/cs-CZ/opencadstudio.ftl | 3 + locales/de-DE/opencadstudio.ftl | 3 + locales/el-GR/opencadstudio.ftl | 3 + locales/en-US/opencadstudio.ftl | 3 + locales/es-ES/opencadstudio.ftl | 3 + locales/fi-FI/opencadstudio.ftl | 3 + locales/fr-FR/opencadstudio.ftl | 3 + locales/hi-IN/opencadstudio.ftl | 3 + locales/hu-HU/opencadstudio.ftl | 3 + locales/it-IT/opencadstudio.ftl | 3 + locales/ja-JP/opencadstudio.ftl | 3 + locales/ko-KR/opencadstudio.ftl | 3 + locales/lo-LA/opencadstudio.ftl | 3 + locales/nl-NL/opencadstudio.ftl | 3 + locales/pl-PL/opencadstudio.ftl | 3 + locales/pt-BR/opencadstudio.ftl | 3 + locales/ru-RU/opencadstudio.ftl | 3 + locales/tr-TR/opencadstudio.ftl | 3 + locales/zh-CN/opencadstudio.ftl | 3 + locales/zh-TW/opencadstudio.ftl | 3 + src/app/mod.rs | 22 +- src/app/update/mod.rs | 97 +- src/app/update/style.rs | 6 +- src/app/update/viewport.rs | 4 +- src/app/view/mod.rs | 45 +- src/locale_catalog.rs | 3 + src/ui/color_select/math.rs | 289 +++++ .../{color_select.rs => color_select/mod.rs} | 210 +--- src/ui/color_select/picker.rs | 1109 +++++++++++++++++ src/ui/color_select/widgets/color_area.rs | 258 ++++ .../color_select/widgets/gradient_slider.rs | 319 +++++ src/ui/color_select/widgets/mod.rs | 7 + 36 files changed, 2173 insertions(+), 276 deletions(-) create mode 100644 src/ui/color_select/math.rs rename src/ui/{color_select.rs => color_select/mod.rs} (58%) create mode 100644 src/ui/color_select/picker.rs create mode 100644 src/ui/color_select/widgets/color_area.rs create mode 100644 src/ui/color_select/widgets/gradient_slider.rs create mode 100644 src/ui/color_select/widgets/mod.rs diff --git a/Cargo.lock b/Cargo.lock index eb9ef6385..92ac4af32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2535,6 +2535,8 @@ dependencies = [ "iced_core", "iced_fonts_macros", "iced_widget", + "num-format", + "num-traits", "web-time", ] @@ -3732,6 +3734,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 992eee086..89d31c359 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,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", "color_picker", "drop_down", "number_input"] } bytemuck = { version = "1.25", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 8aedf067d..358066656 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1339,6 +1339,9 @@ common = .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = سداسي عشري + .nearest-index-color = أقرب فهرس + .exact-index-match = تطابق تام مع الفهرس .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 diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 69c6c9f27..3f7094552 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1326,6 +1326,9 @@ common = .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 = Точно съвпадение с индекс .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 diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 94e44eb2b..cad06d273 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1326,6 +1326,9 @@ common = .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 .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 diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 44e38097d..3c4f4b9d5 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1327,6 +1327,9 @@ common = .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 .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 diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 1eb4b91ab..615a14c6d 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1328,6 +1328,9 @@ common = .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 = Ακριβής αντιστοίχιση δείκτη .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 diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 377caf653..c4c86910b 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1328,6 +1328,9 @@ common = .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 .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 diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index c3b82398b..c21e510bc 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1327,6 +1327,9 @@ common = .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 .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 diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 6503a7115..c8067d189 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1326,6 +1326,9 @@ common = .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 .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 diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index df694d068..014bb5e83 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1327,6 +1327,9 @@ common = .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 .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 diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index fc162ca59..8a35dd54e 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1315,6 +1315,9 @@ common = .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = हेक्स + .nearest-index-color = निकटतम इंडेक्स + .exact-index-match = इंडेक्स से सटीक मेल .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 diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index ee0e196ab..4e0c17b55 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1326,6 +1326,9 @@ common = .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = Hex + .nearest-index-color = Legközelebbi index + .exact-index-match = Pontos indexegyezés .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 diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index f4bdd595f..f1f5b6d5b 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1326,6 +1326,9 @@ common = .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 .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 diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 356c801f5..c13aead95 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1317,6 +1317,9 @@ common = .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 = インデックスと完全一致 .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 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 07e0d9bd0..9744e1e36 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1326,6 +1326,9 @@ common = .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 = 인덱스와 정확히 일치 .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 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index a5d212468..2082db648 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1328,6 +1328,9 @@ common = .index-color = ສີດັດຊະນີ .true-color = True Color .standard-colors = ສີມາດຕະຖານ + .color-hex = Hex + .nearest-index-color = ດັດຊະນີທີ່ໃກ້ທີ່ສຸດ + .exact-index-match = ກົງກັບດັດຊະນີພໍດີ .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .color-family-shade-intensity = ກຸ່ມສີ → · ລະດັບສີ / ຄວາມເຂັ້ມ ↓ .grayscale-250-255 = ລະດັບສີເທົາ 250–255 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 28e91b02a..fdf8b12df 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1325,6 +1325,9 @@ common = .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 .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 diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 7044544a6..c0ace6edb 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1326,6 +1326,9 @@ common = .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 .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 diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 7a8e2ad56..95c7f9245 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1327,6 +1327,9 @@ common = .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 .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 diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index b078726bd..29224ec62 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1330,6 +1330,9 @@ common = .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 = Точное совпадение с индексом .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 diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 54a1c9daf..a5246382f 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1313,6 +1313,9 @@ common = .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 .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 diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 5eb0d8315..0af74a805 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1314,6 +1314,9 @@ common = .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = 十六进制 + .nearest-index-color = 最接近的索引色 + .exact-index-match = 与索引色完全匹配 .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 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 40e3fb081..505cb7f59 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1326,6 +1326,9 @@ common = .index-color = { common.color } (ACI) .true-color = { common.color } (RGB) .standard-colors = { common.standard } · { common.color } + .color-hex = 十六進位 + .nearest-index-color = 最接近的索引色 + .exact-index-match = 與索引色完全相符 .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 diff --git a/src/app/mod.rs b/src/app/mod.rs index da81eacd5..1ec39933d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -894,10 +894,8 @@ pub(super) struct OpenCADStudio { #[cfg(target_arch = "wasm32")] pending_web_thumbnail_save: Option, // ── 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, /// Recently committed real colours, newest first. /// ACI and True Color remain semantically distinct even when their RGB matches. recent_colors: Vec, @@ -1590,13 +1588,6 @@ pub enum BgTarget { Desk, } -/// 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 { @@ -3911,10 +3902,8 @@ 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. @@ -4318,8 +4307,7 @@ impl OpenCADStudio { pending_native_thumbnail_save: None, #[cfg(target_arch = "wasm32")] pending_web_thumbnail_save: None, - color_pick_target: None, - color_picker_tab: ColorPickerTab::Index, + color_picker: None, recent_colors: Vec::new(), active_modal: None, hyperlink_editor_handles: Vec::new(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ae3330e03..a65093e5d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -377,6 +377,37 @@ impl OpenCADStudio { // closes it and every other keystroke-derived message is swallowed; // the modal's own text fields keep working because they emit their own // (non-blocked) messages. (#126) + // 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; + let last_key = |key: &str| key.rsplit('+').next().map(str::to_owned); + match &msg { + Message::CommandEscape => { + return self.update(Message::ColorPicker(ColorPickerMsg::Cancel)); + } + Message::CommandFinalize => { + return self.update(Message::ColorPicker(ColorPickerMsg::Ok)); + } + Message::ShortcutPressed(key) => match last_key(key).as_deref() { + Some("ESCAPE") => { + return self.update(Message::ColorPicker(ColorPickerMsg::Cancel)); + } + Some("ENTER" | "RETURN") => { + return self.update(Message::ColorPicker(ColorPickerMsg::Ok)); + } + _ => {} + }, + Message::ArrowKeyPressed { direction, .. } => { + return self.update(Message::ColorPicker(ColorPickerMsg::Arrow(*direction))); + } + _ => {} + } + if is_modal_blocked_key_msg(&msg) { + return Task::none(); + } + } if self.active_modal.is_some() { if matches!(msg, Message::CommandEscape) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ESCAPE")) @@ -3806,7 +3837,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); @@ -11005,10 +11036,10 @@ impl OpenCADStudio { Task::none() } Message::OpenColorWindow(target, color) => { - 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) = self.color_picker_current(&target, color); + self.color_picker = Some(crate::ui::color_select::ColorPicker::new( + target, current, varies, + )); self.modal_offset = iced::Vector::ZERO; self.ds_color_open = None; self.mls_color_open = None; @@ -11023,18 +11054,23 @@ 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)) => { + self.update(Message::ColorWindowPick(color)) + } + Some(crate::ui::color_select::Outcome::Cancel) => { + self.color_picker = None; + Task::none() + } + None => Task::none(), } - Task::none() } Message::CloseColorPicker => { - self.color_pick_target = None; + self.color_picker = None; Task::none() } @@ -11046,6 +11082,39 @@ impl OpenCADStudio { } } + /// The colour the Select Color panel shows as "Current", and whether the + /// selection it edits has mixed colours. A mixed selection starts from the + /// colour of its most recently drawn object (the highest handle). + fn color_picker_current( + &self, + target: &crate::app::ColorPickTarget, + color: codec::types::Color, + ) -> (codec::types::Color, bool) { + if !matches!(target, crate::app::ColorPickTarget::Properties) { + return (color, false); + } + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + 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)) = colors.first() else { + return (color, false); + }; + if colors.iter().all(|&(_, c)| c == first) { + return (first, false); + } + colors.sort_by_key(|&(handle, _)| handle); + let newest = colors.last().map_or(color, |&(_, c)| c); + (newest, true) + } + 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. diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 8bf8fb7e1..97994f40c 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1137,10 +1137,10 @@ impl OpenCADStudio { pub(super) fn on_color_window_pick(&mut self, color: codec::types::Color) -> Task { 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)), @@ -1155,7 +1155,7 @@ impl OpenCADStudio { 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::DimStyle(f)) => Some(Message::DsEdit(f, s)), Some(crate::app::ColorPickTarget::MLeader(f)) => { Some(Message::MLeaderStyleEdit { field: f, value: s }) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 10b270e96..b5a71446d 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1119,7 +1119,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(); } @@ -1207,7 +1207,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 d8427bc5a..19dbec8ab 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2474,42 +2474,15 @@ 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() { + 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/locale_catalog.rs b/src/locale_catalog.rs index e41c57042..2a01c1f43 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4694,6 +4694,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "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")), "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")), diff --git a/src/ui/color_select/math.rs b/src/ui/color_select/math.rs new file mode 100644 index 000000000..a2b6b9c0e --- /dev/null +++ b/src/ui/color_select/math.rs @@ -0,0 +1,289 @@ +//! Colour maths and text parsing for the Select Color panel. +//! +//! Everything here is pure so it can be unit tested without a UI: HSB/RGB +//! conversion, hex formatting, nearest-index lookup and the parser behind the +//! panel's Color field. + +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 } +} + +/// 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, and whether it is an exact match. +pub fn nearest_aci_match(r: u8, g: u8, b: u8) -> (u8, bool) { + let mut best = 7; + let mut best_distance = u32::MAX; + for index in 1..=255 { + let Some((ar, ag, ab)) = aci_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, best_distance == 0) +} + +/// `#RRGGBB`, upper case. +pub fn hex_string(r: u8, g: u8, b: u8) -> String { + format!("#{r:02X}{g:02X}{b:02X}") +} + +/// Parse `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB`. +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)) +} + +/// Parse what the user typed in the Color field. +/// +/// Accepts an index (`140`, `aci 140`), a standard colour name (`red`), an +/// `r,g,b` triple, or hex (`#39BFFC`). Logical colours are not accepted: the +/// panel has no ByLayer / ByBlock entry. +pub fn parse_color_input(text: &str) -> Option { + let text = text.trim().to_ascii_lowercase(); + if text.is_empty() { + return None; + } + let named = match text.as_str() { + "red" => Some(1), + "yellow" => Some(2), + "green" => Some(3), + "cyan" => Some(4), + "blue" => Some(5), + "magenta" => Some(6), + "white" | "black" => Some(7), + _ => None, + }; + if let Some(index) = named { + return Some(AcadColor::Index(index)); + } + let number = text.strip_prefix("aci").map(str::trim).unwrap_or(&text); + if !number.is_empty() && number.chars().all(|c| c.is_ascii_digit()) { + return match number.parse::() { + Ok(n @ 1..=255) => Some(AcadColor::Index(n as u8)), + _ => None, + }; + } + if text.contains(',') { + let parts: Vec<&str> = text.split(',').map(str::trim).collect(); + if let [r, g, b] = parts.as_slice() { + let (r, g, b) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?); + return Some(AcadColor::Rgb { r, g, b }); + } + return None; + } + if text.starts_with('#') || text.len() == 6 { + let (r, g, b) = parse_hex(&text)?; + return Some(AcadColor::Rgb { r, g, b }); + } + None +} + +/// The text the Color field shows for a colour. +pub fn color_field_text(color: AcadColor) -> String { + match color { + AcadColor::Index(i) => i.to_string(), + AcadColor::Rgb { r, g, b } => format!("{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 => color_field_text(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_reports_exact_matches() { + assert_eq!(nearest_aci_match(255, 0, 0), (1, true)); + let (index, exact) = nearest_aci_match(250, 5, 5); + assert_eq!(index, 1); + assert!(!exact); + } + + #[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); + } + + #[test] + fn colour_field_accepts_index_name_rgb_and_hex() { + assert_eq!(parse_color_input("140"), Some(AcadColor::Index(140))); + assert_eq!(parse_color_input(" ACI 7 "), Some(AcadColor::Index(7))); + assert_eq!(parse_color_input("Red"), Some(AcadColor::Index(1))); + assert_eq!( + parse_color_input("57, 191,252"), + Some(AcadColor::Rgb { + r: 57, + g: 191, + b: 252 + }) + ); + assert_eq!( + parse_color_input("#39BFFC"), + Some(AcadColor::Rgb { + r: 57, + g: 191, + b: 252 + }) + ); + } + + #[test] + fn colour_field_rejects_logical_and_out_of_range_values() { + assert_eq!(parse_color_input("bylayer"), None); + assert_eq!(parse_color_input("byblock"), None); + assert_eq!(parse_color_input("0"), None); + assert_eq!(parse_color_input("256"), None); + assert_eq!(parse_color_input("300,0,0"), None); + assert_eq!(parse_color_input("1,2"), None); + assert_eq!(parse_color_input(""), None); + } + + #[test] + fn field_text_matches_parser() { + for color in [ + AcadColor::Index(140), + AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }, + ] { + assert_eq!(parse_color_input(&color_field_text(color)), Some(color)); + } + } +} 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 c6efde206..5825e4d56 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select/mod.rs @@ -3,6 +3,12 @@ //! properties panel and every style editor so colour selection looks and //! behaves the same everywhere. +pub mod math; +pub mod picker; +pub mod widgets; + +pub use picker::{ColorPicker, ColorPickerMsg, ColorPickerTab, Outcome, TrueColorMode}; + use crate::app::Message; use crate::t; use crate::ui::properties::acad_color_display; @@ -34,40 +40,9 @@ 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 + math::nearest_aci_match(r, g, b).0 } /// Decode an ACI integer string back into an `AcadColor`. @@ -350,174 +325,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..ee98e49b4 --- /dev/null +++ b/src/ui/color_select/picker.rs @@ -0,0 +1,1109 @@ +//! 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. + +use super::math::{self, Hsb}; +use super::widgets::{ColorArea, GradientSlider}; +use crate::app::{ArrowKey, ColorPickTarget, Message}; +use crate::t; +use crate::ui::properties::acad_color_display; +use codec::types::Color as AcadColor; +use iced::widget::{button, column, container, row, text, text_input, Space}; +use iced::{Background, Border, Color, Element, Length, Theme}; + +const PANEL_WIDTH: f32 = 400.0; +const BODY_HEIGHT: f32 = 300.0; +const CONTROL_HEIGHT: f32 = 28.0; +const SWATCH_WIDTH: f32 = 104.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ColorPickerTab { + #[default] + Index, + TrueColor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TrueColorMode { + #[default] + Hsb, + Rgb, + Gradient, +} + +#[derive(Debug, Clone)] +pub enum ColorPickerMsg { + Tab(ColorPickerTab), + Mode(TrueColorMode), + /// A swatch was clicked. + Pick(AcadColor), + /// Set the new colour back to the current one. + Revert, + Hue(f32), + Saturation(f32), + Brightness(f32), + SaturationBrightness(f32, f32), + Red(u8), + Green(u8), + Blue(u8), + FieldInput(String), + FieldSubmit, + HexInput(String), + HexSubmit, + /// Replace the true colour with its nearest index colour. + UseNearest, + Arrow(ArrowKey), + Ok, + Cancel, +} + +/// What the caller has to do after an update. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Outcome { + Commit(AcadColor), + Cancel, +} + +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, + tab: ColorPickerTab, + mode: TrueColorMode, + /// Kept separately from `pending` so the hue survives grays and black. + hsb: Hsb, + field_draft: Option, + hex_draft: Option, +} + +impl ColorPicker { + pub fn new(target: ColorPickTarget, current: AcadColor, varies: bool) -> Self { + let tab = if matches!(current, AcadColor::Rgb { .. }) { + ColorPickerTab::TrueColor + } else { + ColorPickerTab::Index + }; + let mut picker = Self { + target, + current, + varies, + pending: current, + tab, + mode: TrueColorMode::default(), + hsb: Hsb { + h: 0.0, + s: 0.0, + b: 1.0, + }, + field_draft: None, + hex_draft: None, + }; + picker.set_pending(current); + picker + } + + pub fn pending(&self) -> AcadColor { + self.pending + } + + pub fn tab(&self) -> ColorPickerTab { + self.tab + } + + pub fn mode(&self) -> TrueColorMode { + self.mode + } + + fn set_pending(&mut self, color: AcadColor) { + self.pending = color; + self.field_draft = None; + self.hex_draft = None; + if let Some((r, g, b)) = math::color_rgb(color) { + let next = math::rgb_to_hsb(r, g, b); + self.hsb = Hsb { + // A gray or black has no hue of its own; keep the last one so + // the sliders and field don't jump back to red. + h: if next.s == 0.0 || next.b == 0.0 { + self.hsb.h + } else { + next.h + }, + s: if next.b == 0.0 { self.hsb.s } else { next.s }, + b: next.b, + }; + } + } + + 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), + }; + let (r, g, b) = self.hsb.to_rgb(); + self.pending = AcadColor::Rgb { r, g, b }; + self.field_draft = None; + self.hex_draft = None; + } + + fn set_rgb(&mut self, r: u8, g: u8, b: u8) { + self.set_pending(AcadColor::Rgb { r, g, b }); + } + + /// RGB of the pending colour; logical colours fall back to the sliders. + fn rgb(&self) -> (u8, u8, u8) { + math::color_rgb(self.pending).unwrap_or_else(|| self.hsb.to_rgb()) + } + + pub fn update(&mut self, msg: ColorPickerMsg) -> Option { + match msg { + ColorPickerMsg::Tab(tab) => self.tab = tab, + ColorPickerMsg::Mode(mode) => self.mode = mode, + ColorPickerMsg::Pick(color) => self.set_pending(color), + ColorPickerMsg::Revert => self.set_pending(self.current), + ColorPickerMsg::Hue(h) => self.set_hsb(Hsb { h, ..self.hsb }), + ColorPickerMsg::Saturation(s) => self.set_hsb(Hsb { s, ..self.hsb }), + ColorPickerMsg::Brightness(b) => self.set_hsb(Hsb { b, ..self.hsb }), + ColorPickerMsg::SaturationBrightness(s, b) => { + self.set_hsb(Hsb { s, b, ..self.hsb }) + } + ColorPickerMsg::Red(red) => { + let (_, g, b) = self.rgb(); + self.set_rgb(red, g, b); + } + ColorPickerMsg::Green(green) => { + let (r, _, b) = self.rgb(); + self.set_rgb(r, green, b); + } + ColorPickerMsg::Blue(blue) => { + let (r, g, _) = self.rgb(); + self.set_rgb(r, g, blue); + } + ColorPickerMsg::FieldInput(value) => self.field_draft = Some(value), + ColorPickerMsg::FieldSubmit => { + if let Some(draft) = self.field_draft.take() { + if let Some(color) = math::parse_color_input(&draft) { + self.set_pending(color); + } + } + } + ColorPickerMsg::HexInput(value) => self.hex_draft = Some(value), + ColorPickerMsg::HexSubmit => { + if let Some(draft) = self.hex_draft.take() { + if let Some((r, g, b)) = math::parse_hex(&draft) { + self.set_rgb(r, g, b); + } + } + } + ColorPickerMsg::UseNearest => { + let (r, g, b) = self.rgb(); + let (index, _) = math::nearest_aci_match(r, g, b); + self.set_pending(AcadColor::Index(index)); + } + ColorPickerMsg::Arrow(direction) => { + if self.tab == ColorPickerTab::Index { + if let AcadColor::Index(index) = self.pending { + if let Some(next) = step_index(index, direction) { + self.set_pending(AcadColor::Index(next)); + } + } + } + } + ColorPickerMsg::Ok => { + // Apply a value still being typed, as if Enter had been pressed. + self.update(ColorPickerMsg::FieldSubmit); + self.update(ColorPickerMsg::HexSubmit); + return Some(Outcome::Commit(self.pending)); + } + ColorPickerMsg::Cancel => return Some(Outcome::Cancel), + } + None + } + + pub fn view(&self) -> Element<'_, Message> { + let body = match self.tab { + ColorPickerTab::Index => self.index_tab(), + ColorPickerTab::TrueColor => self.true_color_tab(), + }; + + let (sr, sg, sb) = math::color_rgb(self.pending).unwrap_or((128, 128, 128)); + let info = row![ + container(Space::new()) + .width(12) + .height(12) + .style(move |theme: &Theme| swatch_style(theme, rgb_color(sr, sg, sb))), + text(math::describe(self.pending)).size(11).font(iced::Font::MONOSPACE), + ] + .spacing(8) + .align_y(iced::Center); + + let body = container( + column![ + body, + Space::new().height(Length::Fill), + container(Space::new()) + .width(Length::Fill) + .height(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.weak.color + )), + ..Default::default() + }), + info, + ] + .spacing(8), + ) + .padding(12) + .width(Length::Fill) + .height(Length::Fixed(BODY_HEIGHT)) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weakest.color)), + border: Border { + color: palette.background.weak.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } + }); + + 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); + + column![self.tabs(), self.preview_row(), body, actions] + .spacing(12) + .width(Length::Fixed(PANEL_WIDTH)) + .into() + } + + fn tabs(&self) -> Element<'_, Message> { + let tab = |label: String, tab: ColorPickerTab| { + let active = self.tab == tab; + button( + container(text(label).size(12)) + .width(Length::Fill) + .align_x(iced::Center), + ) + .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!("Index Color").into_owned(), ColorPickerTab::Index), + tab(t!("True Color").into_owned(), ColorPickerTab::TrueColor), + ] + .spacing(2), + ) + .padding(2) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weakest.color)), + border: Border { + color: palette.background.weak.color, + width: 1.0, + radius: 7.0.into(), + }, + ..Default::default() + } + }) + .into() + } + + fn preview_row(&self) -> Element<'_, Message> { + let caption = |label: String| text(label).size(11).style(muted_text); + + let (current_bg, _) = acad_color_display(self.current); + let (new_bg, _) = acad_color_display(self.pending); + let current_label = if self.varies { + "*VARIES*".to_string() + } else { + logical_badge(self.current) + }; + let swatch_text = |label: String, background: Color| { + let ink = if ink_is_dark(background) { + Color::from_rgb8(0x11, 0x12, 0x14) + } else { + Color::WHITE + }; + container( + text(label) + .size(9) + .font(iced::Font { + weight: iced::font::Weight::Bold, + ..iced::Font::DEFAULT + }) + .color(ink), + ) + .center(Length::Fill) + }; + let current = button(swatch_text(current_label, 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(Background::Color(current_bg)), + ..Default::default() + }); + let new = container(swatch_text(logical_badge(self.pending), new_bg)) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme: &Theme| container::Style { + background: Some(Background::Color(new_bg)), + ..Default::default() + }); + let swatch = container(row![current, new]) + .width(SWATCH_WIDTH) + .height(CONTROL_HEIGHT) + .padding(1) + .style(|theme: &Theme| container::Style { + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }) + .clip(true); + + let field_text = self + .field_draft + .clone() + .unwrap_or_else(|| math::color_field_text(self.pending)); + let field = text_input("", &field_text) + .on_input(|value| picker_msg(ColorPickerMsg::FieldInput(value))) + .on_submit(picker_msg(ColorPickerMsg::FieldSubmit)) + .size(12) + .font(iced::Font::MONOSPACE) + .padding([6, 10]) + .width(Length::Fill); + + row![ + column![ + row![ + caption(t!("Current").into_owned()), + Space::new().width(Length::Fill), + caption(t!("New").into_owned()), + ] + .width(SWATCH_WIDTH), + swatch, + ] + .spacing(4), + column![caption(t!("Color").into_owned()), field] + .spacing(4) + .width(Length::Fill), + ] + .spacing(10) + .align_y(iced::Bottom) + .into() + } + + fn index_tab(&self) -> Element<'_, Message> { + let section = |label: String| text(label).size(11); + + let mut standard = row![].spacing(5); + for index in 1u8..=9 { + standard = standard.push(self.swatch_button(index, 30.0, 26.0)); + } + + // ACI 10–249 come in groups of ten: 24 hue families across, their ten + // shades down. + let mut grid = column![].spacing(2); + for shade in 0u8..10 { + let mut line = row![].spacing(2); + for family in 1u8..=24 { + line = line.push(self.swatch_button(family * 10 + shade, 13.0, 13.0)); + } + grid = grid.push(line); + } + + let mut grays = row![].spacing(5); + for index in 250u8..=255 { + grays = grays.push(self.swatch_button(index, 30.0, 26.0)); + } + + column![ + column![section(t!("Standard colors").into_owned()), standard].spacing(6), + column![ + row![ + section(t!("CAD Color Index (ACI) 10–249").into_owned()), + Space::new().width(Length::Fill), + ], + grid, + ] + .spacing(6), + column![section(t!("Grayscale 250–255").into_owned()), grays].spacing(6), + ] + .spacing(12) + .into() + } + + fn swatch_button(&self, index: u8, width: f32, height: f32) -> Element<'_, Message> { + let (r, g, b) = math::aci_rgb(index).unwrap_or((128, 128, 128)); + let fill = rgb_color(r, g, b); + let selected = self.pending == AcadColor::Index(index); + button(Space::new().width(width).height(height)) + .on_press(picker_msg(ColorPickerMsg::Pick(AcadColor::Index(index)))) + .padding(0) + .style(move |theme: &Theme, status| { + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + let accent = theme.palette().primary.base.color; + button::Style { + background: Some(Background::Color(fill)), + border: Border { + color: if selected || hovered { + accent + } 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() + } + }) + .into() + } + + fn true_color_tab(&self) -> Element<'_, Message> { + let mode_button = |content: Element<'static, Message>, mode: TrueColorMode| { + let active = self.mode == mode; + button(container(content).center_y(Length::Fill)) + .on_press(picker_msg(ColorPickerMsg::Mode(mode))) + .height(CONTROL_HEIGHT) + .padding([0, 10]) + .style(move |theme: &Theme, status| toggle_style(theme, active, status)) + }; + let gradient_icon: Element<'static, Message> = container(Space::new()) + .width(26) + .height(16) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Gradient(iced::Gradient::Linear( + iced::gradient::Linear::new(iced::Radians(std::f32::consts::FRAC_PI_4 * 3.0)) + .add_stop(0.0, Color::WHITE) + .add_stop(0.5, Color::from_rgb8(0x3A, 0xA0, 0xFF)) + .add_stop(1.0, Color::BLACK), + ))), + border: Border { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.3), + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }) + .into(); + let modes = row![ + mode_button(text("HSB").size(12).into(), TrueColorMode::Hsb), + mode_button(text("RGB").size(12).into(), TrueColorMode::Rgb), + mode_button(gradient_icon, TrueColorMode::Gradient), + ] + .spacing(6); + + let editor = match self.mode { + TrueColorMode::Hsb => self.hsb_sliders(), + TrueColorMode::Rgb => self.rgb_sliders(), + TrueColorMode::Gradient => self.gradient_editor(), + }; + + column![modes, editor, self.hex_row()].spacing(12).into() + } + + fn hsb_sliders(&self) -> Element<'_, Message> { + let Hsb { h, s, b } = self.hsb; + let hue_stops = hue_stops(); + let sat_stops = vec![ + (0.0, hsb_color(h, 0.0, b)), + (1.0, hsb_color(h, 1.0, b)), + ]; + let bright_stops = vec![(0.0, Color::BLACK), (1.0, hsb_color(h, s, 1.0))]; + column![ + slider_row( + "H", + GradientSlider::new(h / 360.0, hue_stops, |v| picker_msg(ColorPickerMsg::Hue( + v * 360.0 + ))) + .wheel_step(1.0 / 360.0), + number_box(h.round() as u16, 360, |v| { + picker_msg(ColorPickerMsg::Hue(f32::from(v))) + }), + "°", + ), + slider_row( + "S", + GradientSlider::new(s, sat_stops, |v| picker_msg(ColorPickerMsg::Saturation(v))), + number_box((s * 100.0).round() as u16, 100, |v| { + picker_msg(ColorPickerMsg::Saturation(f32::from(v) / 100.0)) + }), + "%", + ), + slider_row( + "B", + GradientSlider::new(b, bright_stops, |v| picker_msg(ColorPickerMsg::Brightness( + v + ))), + number_box((b * 100.0).round() as u16, 100, |v| { + picker_msg(ColorPickerMsg::Brightness(f32::from(v) / 100.0)) + }), + "%", + ), + ] + .spacing(10) + .into() + } + + fn rgb_sliders(&self) -> Element<'_, Message> { + let (r, g, b) = self.rgb(); + let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| { + vec![(0.0, rgb_color(lo.0, lo.1, lo.2)), (1.0, rgb_color(hi.0, hi.1, hi.2))] + }; + let channel = |value: u8| f32::from(value) / 255.0; + let byte = |v: f32| (v * 255.0).round() as u8; + column![ + slider_row( + "R", + GradientSlider::new(channel(r), ends((0, g, b), (255, g, b)), move |v| { + picker_msg(ColorPickerMsg::Red(byte(v))) + }) + .wheel_step(1.0 / 255.0), + number_box(u16::from(r), 255, |v| picker_msg(ColorPickerMsg::Red(v as u8))), + "", + ), + slider_row( + "G", + GradientSlider::new(channel(g), ends((r, 0, b), (r, 255, b)), move |v| { + picker_msg(ColorPickerMsg::Green(byte(v))) + }) + .wheel_step(1.0 / 255.0), + number_box(u16::from(g), 255, |v| picker_msg(ColorPickerMsg::Green(v as u8))), + "", + ), + slider_row( + "B", + GradientSlider::new(channel(b), ends((r, g, 0), (r, g, 255)), move |v| { + picker_msg(ColorPickerMsg::Blue(byte(v))) + }) + .wheel_step(1.0 / 255.0), + number_box(u16::from(b), 255, |v| picker_msg(ColorPickerMsg::Blue(v as u8))), + "", + ), + ] + .spacing(10) + .into() + } + + fn gradient_editor(&self) -> Element<'_, Message> { + let Hsb { h, s, b } = self.hsb; + let (r, g, bl) = self.rgb(); + let area = ColorArea::new(hsb_color(h, 1.0, 1.0), rgb_color(r, g, bl), s, b, |s, b| { + picker_msg(ColorPickerMsg::SaturationBrightness(s, b)) + }) + .height(Length::Fixed(118.0)); + let hue = GradientSlider::new(h / 360.0, hue_stops(), |v| { + picker_msg(ColorPickerMsg::Hue(v * 360.0)) + }) + .vertical() + .height(Length::Fixed(118.0)) + .wheel_step(1.0 / 360.0); + + let field = |letter: &'static str, input: Element<'static, Message>, unit: &'static str| { + row![ + text(letter).size(12).font(iced::Font::MONOSPACE), + input, + text(unit).size(11).style(muted_text), + ] + .spacing(4) + .align_y(iced::Center) + }; + let values = row![ + field( + "H", + number_box(h.round() as u16, 360, |v| picker_msg(ColorPickerMsg::Hue(f32::from( + v + )))), + "°", + ), + Space::new().width(Length::Fill), + field( + "S", + number_box((s * 100.0).round() as u16, 100, |v| { + picker_msg(ColorPickerMsg::Saturation(f32::from(v) / 100.0)) + }), + "%", + ), + Space::new().width(Length::Fill), + field( + "B", + number_box((b * 100.0).round() as u16, 100, |v| { + picker_msg(ColorPickerMsg::Brightness(f32::from(v) / 100.0)) + }), + "%", + ), + ] + .align_y(iced::Center); + + column![row![area, hue].spacing(12), values] + .spacing(10) + .into() + } + + fn hex_row(&self) -> Element<'_, Message> { + let (r, g, b) = self.rgb(); + let hex = self + .hex_draft + .clone() + .unwrap_or_else(|| math::hex_string(r, g, b)); + let (nearest, exact) = math::nearest_aci_match(r, g, b); + let is_index = matches!(self.pending, AcadColor::Index(_)); + let match_label = if exact && !is_index { + t!("Exact index match") + } else { + t!("Nearest index") + }; + let (nr, ng, nb) = math::aci_rgb(nearest).unwrap_or((128, 128, 128)); + let nearest_fill = rgb_color(nr, ng, nb); + let highlight = exact && !is_index; + row![ + text(t!("Hex")).size(11).style(muted_text), + text_input("", &hex) + .on_input(|value| picker_msg(ColorPickerMsg::HexInput(value))) + .on_submit(picker_msg(ColorPickerMsg::HexSubmit)) + .size(12) + .font(iced::Font::MONOSPACE) + .padding([5, 8]) + .width(Length::Fixed(92.0)), + Space::new().width(Length::Fill), + text(match_label).size(11).style(move |theme: &Theme| { + let palette = theme.palette(); + text::Style { + color: Some(if highlight { + palette.success.base.color + } else { + palette.background.base.text.scale_alpha(0.65) + }), + } + }), + button( + row![ + container(Space::new()) + .width(16) + .height(16) + .style(move |theme: &Theme| swatch_style(theme, nearest_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, highlight, 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 { + match index { + 1..=9 => match direction { + ArrowKey::Left if index > 1 => Some(index - 1), + ArrowKey::Right if index < 9 => Some(index + 1), + _ => None, + }, + 10..=249 => { + let family = index / 10; + let shade = 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, + } + } + 250..=255 => match direction { + ArrowKey::Left if index > 250 => Some(index - 1), + ArrowKey::Right if index < 255 => Some(index + 1), + _ => None, + }, + _ => None, + } +} + +fn picker_msg(msg: ColorPickerMsg) -> Message { + Message::ColorPicker(msg) +} + +fn slider_row<'a>( + letter: &'static str, + slider: GradientSlider<'a, Message>, + number: Element<'a, Message>, + unit: &'static str, +) -> Element<'a, Message> { + row![ + container(text(letter).size(12).font(iced::Font::MONOSPACE)) + .width(12) + .align_x(iced::Center), + slider, + number, + container(text(unit).size(11).style(muted_text)).width(10), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A compact spin box: iced_aw's NumberInput keeps its stacked arrows as long +/// as its padding stays at the default. +fn number_box( + value: u16, + max: u16, + on_change: impl Fn(u16) -> Message + Clone + 'static, +) -> Element<'static, Message> { + iced_aw::widget::NumberInput::new(&value, 0..=max, on_change) + .set_size(12.0) + .font(iced::Font::MONOSPACE) + .width(Length::Fixed(64.0)) + .align_x(iced::alignment::Horizontal::Right) + .into() +} + +fn hue_stops() -> Vec<(f32, Color)> { + (0..=6) + .map(|step| { + let offset = step as f32 / 6.0; + (offset, hsb_color(offset * 360.0, 1.0, 1.0)) + }) + .collect() +} + +fn hsb_color(h: f32, s: f32, b: f32) -> Color { + let (r, g, b) = math::hsb_to_rgb(h, s, b); + rgb_color(r, g, b) +} + +fn rgb_color(r: u8, g: u8, b: u8) -> Color { + Color::from_rgb8(r, g, b) +} + +fn ink_is_dark(color: Color) -> bool { + math::ink_is_dark( + (color.r * 255.0) as u8, + (color.g * 255.0) as u8, + (color.b * 255.0) as u8, + ) +} + +/// Upper-case label for a logical colour shown on a swatch, empty otherwise. +fn logical_badge(color: AcadColor) -> String { + match color { + AcadColor::ByLayer => "BYLAYER".to_string(), + AcadColor::ByBlock => "BYBLOCK".to_string(), + AcadColor::None => "NONE".to_string(), + _ => String::new(), + } +} + +fn muted_text(theme: &Theme) -> text::Style { + text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.65)), + } +} + +fn swatch_style(_theme: &Theme, fill: Color) -> container::Style { + container::Style { + background: Some(Background::Color(fill)), + border: Border { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } +} + +fn segment_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { + let palette = theme.palette(); + let hovered = matches!(status, button::Status::Hovered); + button::Style { + background: if active { + Some(Background::Color(palette.background.strong.color)) + } else if hovered { + Some(Background::Color(palette.background.weak.color)) + } 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() + } +} + +fn toggle_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { + let palette = theme.palette(); + let hovered = matches!(status, button::Status::Hovered); + button::Style { + background: Some(Background::Color(if active { + palette.primary.weak.color + } else if 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::*; + + fn picker(current: AcadColor) -> ColorPicker { + ColorPicker::new(ColorPickTarget::Ribbon, current, false) + } + + #[test] + fn opens_on_the_tab_that_fits_the_current_colour() { + assert_eq!(picker(AcadColor::Index(30)).tab(), ColorPickerTab::Index); + assert_eq!( + picker(AcadColor::Rgb { r: 1, g: 2, b: 3 }).tab(), + ColorPickerTab::TrueColor + ); + assert_eq!(picker(AcadColor::ByLayer).tab(), ColorPickerTab::Index); + } + + #[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))) + ); + assert_eq!(p.update(ColorPickerMsg::Cancel), Some(Outcome::Cancel)); + } + + #[test] + fn ok_applies_text_still_being_typed() { + let mut p = picker(AcadColor::Index(30)); + p.update(ColorPickerMsg::FieldInput("57,191,252".into())); + assert_eq!( + p.update(ColorPickerMsg::Ok), + Some(Outcome::Commit(AcadColor::Rgb { + r: 57, + g: 191, + b: 252 + })) + ); + } + + #[test] + fn invalid_field_text_keeps_the_colour() { + let mut p = picker(AcadColor::Index(30)); + p.update(ColorPickerMsg::FieldInput("bylayer".into())); + p.update(ColorPickerMsg::FieldSubmit); + assert_eq!(p.pending(), AcadColor::Index(30)); + } + + #[test] + fn sliders_produce_true_colours() { + let mut p = picker(AcadColor::Index(1)); + p.update(ColorPickerMsg::Hue(120.0)); + assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 255, b: 0 }); + p.update(ColorPickerMsg::Red(10)); + assert_eq!(p.pending(), AcadColor::Rgb { r: 10, g: 255, b: 0 }); + } + + #[test] + fn hue_survives_a_trip_through_gray() { + let mut p = picker(AcadColor::Rgb { r: 0, g: 0, b: 255 }); + p.update(ColorPickerMsg::Saturation(0.0)); + p.update(ColorPickerMsg::Saturation(1.0)); + assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 0, b: 255 }); + } + + #[test] + fn hex_field_sets_a_true_colour() { + let mut p = picker(AcadColor::Index(1)); + p.update(ColorPickerMsg::HexInput("#3bf".into())); + p.update(ColorPickerMsg::HexSubmit); + assert_eq!( + p.pending(), + AcadColor::Rgb { + r: 0x33, + g: 0xBB, + b: 0xFF + } + ); + } + + #[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::Arrow(ArrowKey::Right)); + assert_eq!(p.pending(), AcadColor::Index(150)); + } + + // ── 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::default(), + (PANEL_WIDTH + 24.0, 440.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::Mode(TrueColorMode::Rgb)); + let mut ui = simulator(&p); + // The first slider row sits below the tabs, preview row and mode + // buttons; press near its left end. + let red_row = ui.find("R").expect("R label"); + let bounds = red_row.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::Red(v) if *v < 57))); + } + + #[test] + fn every_view_renders() { + let folder = std::env::var_os("OCS_COLOR_PICKER_SNAPSHOTS").map(std::path::PathBuf::from); + let views: [(&str, AcadColor, ColorPickerTab, TrueColorMode, bool); 6] = [ + ("index", AcadColor::Index(140), ColorPickerTab::Index, TrueColorMode::Hsb, false), + ("varies", AcadColor::Index(30), ColorPickerTab::Index, TrueColorMode::Hsb, true), + ("hsb", AcadColor::Rgb { r: 57, g: 191, b: 252 }, ColorPickerTab::TrueColor, TrueColorMode::Hsb, false), + ("rgb", AcadColor::Rgb { r: 57, g: 191, b: 252 }, ColorPickerTab::TrueColor, TrueColorMode::Rgb, false), + ("gradient", AcadColor::Rgb { r: 57, g: 191, b: 252 }, ColorPickerTab::TrueColor, TrueColorMode::Gradient, false), + ("exact", AcadColor::Rgb { r: 255, g: 0, b: 0 }, ColorPickerTab::TrueColor, TrueColorMode::Hsb, false), + ]; + for theme in [ + crate::ui::style::fusion_theme::fusion_black(), + crate::ui::style::fusion_theme::fusion_white(), + ] { + for (name, color, tab, mode, varies) in views { + let mut p = ColorPicker::new(ColorPickTarget::Properties, color, varies); + p.update(ColorPickerMsg::Tab(tab)); + p.update(ColorPickerMsg::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/gradient_slider.rs b/src/ui/color_select/widgets/gradient_slider.rs new file mode 100644 index 000000000..36893e9e3 --- /dev/null +++ b/src/ui/color_select/widgets/gradient_slider.rs @@ -0,0 +1,319 @@ +//! A slider whose track is a colour gradient, with a white bar handle. +//! +//! Used for the HSB and RGB channels of the Select Color panel and, upright, +//! for the hue strip beside the saturation/brightness field. The value is +//! normalised to 0–1; callers map it to their own range. + +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, Shadow, Size, Theme, Vector, +}; + +const TRACK_THICKNESS: f32 = 18.0; +const TRACK_RADIUS: f32 = 5.0; +const HANDLE_THICKNESS: f32 = 8.0; +const HANDLE_RADIUS: f32 = 3.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Orientation { + Horizontal, + Vertical, +} + +pub struct GradientSlider<'a, Message> { + value: f32, + stops: Vec<(f32, Color)>, + on_change: Box Message + 'a>, + orientation: Orientation, + /// How far one mouse-wheel notch moves the value (0–1 units). + wheel_step: f32, + width: Length, + height: Length, +} + +#[derive(Default)] +struct State { + dragging: bool, +} + +impl<'a, Message> GradientSlider<'a, Message> { + /// `value` is 0–1. `stops` are (offset 0–1, colour) pairs, at most eight. + pub fn new( + value: f32, + stops: Vec<(f32, Color)>, + on_change: impl Fn(f32) -> Message + 'a, + ) -> Self { + Self { + value: value.clamp(0.0, 1.0), + stops, + on_change: Box::new(on_change), + orientation: Orientation::Horizontal, + wheel_step: 0.01, + width: Length::Fill, + height: Length::Fixed(26.0), + } + } + + pub fn vertical(mut self) -> Self { + self.orientation = Orientation::Vertical; + self.width = Length::Fixed(22.0); + self.height = Length::Fill; + self + } + + pub fn wheel_step(mut self, step: f32) -> Self { + self.wheel_step = step; + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// The span the handle centre travels along, in the slider's main axis. + fn travel(&self, bounds: Rectangle) -> (f32, f32) { + let inset = HANDLE_THICKNESS / 2.0; + match self.orientation { + Orientation::Horizontal => (bounds.x + inset, bounds.x + bounds.width - inset), + Orientation::Vertical => (bounds.y + inset, bounds.y + bounds.height - inset), + } + } + + fn value_at(&self, bounds: Rectangle, cursor: Point) -> f32 { + let (start, end) = self.travel(bounds); + let along = match self.orientation { + Orientation::Horizontal => cursor.x, + Orientation::Vertical => cursor.y, + }; + if end <= start { + return 0.0; + } + ((along - start) / (end - start)).clamp(0.0, 1.0) + } + + fn track_bounds(&self, bounds: Rectangle) -> Rectangle { + match self.orientation { + Orientation::Horizontal => { + let thickness = TRACK_THICKNESS.min(bounds.height); + Rectangle { + y: bounds.y + (bounds.height - thickness) / 2.0, + height: thickness, + ..bounds + } + } + Orientation::Vertical => bounds, + } + } + + fn gradient(&self) -> Background { + // Gradient angles run clockwise from "bottom to top": a quarter turn + // is left to right, a half turn top to bottom. + let angle = match self.orientation { + Orientation::Horizontal => std::f32::consts::FRAC_PI_2, + Orientation::Vertical => std::f32::consts::PI, + }; + let linear = self + .stops + .iter() + .fold(Linear::new(Radians(angle)), |linear, &(offset, color)| { + linear.add_stop(offset, color) + }); + Background::Gradient(Gradient::Linear(linear)) + } +} + +impl Widget for GradientSlider<'_, 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(); + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(position) = cursor.position_over(bounds) { + state.dragging = true; + shell.publish((self.on_change)(self.value_at(bounds, position))); + shell.capture_event(); + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { + if let Some(position) = cursor.position() { + shell.publish((self.on_change)(self.value_at(bounds, position))); + shell.capture_event(); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => { + state.dragging = false; + shell.capture_event(); + } + Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => { + let lines = match delta { + mouse::ScrollDelta::Lines { y, .. } => *y, + mouse::ScrollDelta::Pixels { y, .. } => y.signum(), + }; + if lines != 0.0 { + // Scrolling up raises the value on a horizontal slider and + // moves the handle up on an upright one. + let direction = match self.orientation { + Orientation::Horizontal => lines.signum(), + Orientation::Vertical => -lines.signum(), + }; + let next = (self.value + direction * self.wheel_step).clamp(0.0, 1.0); + shell.publish((self.on_change)(next)); + 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 { + mouse::Interaction::Grabbing + } else if cursor.is_over(layout.bounds()) { + mouse::Interaction::Pointer + } 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(); + renderer.fill_quad( + renderer::Quad { + bounds: self.track_bounds(bounds), + border: Border { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), + width: 1.0, + radius: TRACK_RADIUS.into(), + }, + ..renderer::Quad::default() + }, + self.gradient(), + ); + + let (start, end) = self.travel(bounds); + let centre = start + (end - start) * self.value; + let handle = match self.orientation { + Orientation::Horizontal => Rectangle { + x: centre - HANDLE_THICKNESS / 2.0, + y: bounds.y, + width: HANDLE_THICKNESS, + height: bounds.height, + }, + Orientation::Vertical => Rectangle { + x: bounds.x - 3.0, + y: centre - HANDLE_THICKNESS / 2.0, + width: bounds.width + 6.0, + height: HANDLE_THICKNESS, + }, + }; + renderer.fill_quad( + renderer::Quad { + bounds: handle, + border: Border { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), + width: 1.0, + radius: HANDLE_RADIUS.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.55), + offset: Vector::new(0.0, 1.0), + blur_radius: 4.0, + }, + ..renderer::Quad::default() + }, + Color::WHITE, + ); + } +} + +impl<'a, Message: 'a> From> for Element<'a, Message> { + fn from(slider: GradientSlider<'a, Message>) -> Self { + Element::new(slider) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn slider() -> GradientSlider<'static, f32> { + GradientSlider::new(0.5, vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], |v| v) + } + + #[test] + fn cursor_maps_to_value_inside_handle_travel() { + let bounds = Rectangle::new(Point::ORIGIN, Size::new(108.0, 26.0)); + let s = slider(); + assert_eq!(s.value_at(bounds, Point::new(4.0, 10.0)), 0.0); + assert_eq!(s.value_at(bounds, Point::new(104.0, 10.0)), 1.0); + assert!((s.value_at(bounds, Point::new(54.0, 10.0)) - 0.5).abs() < 1e-6); + assert_eq!(s.value_at(bounds, Point::new(-50.0, 10.0)), 0.0); + assert_eq!(s.value_at(bounds, Point::new(500.0, 10.0)), 1.0); + } + + #[test] + fn vertical_slider_reads_the_y_axis() { + let bounds = Rectangle::new(Point::ORIGIN, Size::new(22.0, 108.0)); + let s = slider().vertical(); + assert_eq!(s.value_at(bounds, Point::new(11.0, 4.0)), 0.0); + assert_eq!(s.value_at(bounds, Point::new(11.0, 104.0)), 1.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..b11df1c54 --- /dev/null +++ b/src/ui/color_select/widgets/mod.rs @@ -0,0 +1,7 @@ +//! Widgets the Select Color panel needs that iced does not provide. + +mod color_area; +mod gradient_slider; + +pub use color_area::ColorArea; +pub use gradient_slider::GradientSlider; From 43f88732ea44f02199512e1257f0423495bdbf09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:35:12 +0000 Subject: [PATCH 02/32] Fix Select Color panel layout found in screenshots - Give the panel body room for the gray row and info line. - Keep NumberInput digits clear of its arrows and draw the arrows as plain icons in the text colour. - Split the Current / New swatch with a thin divider. - Load the iced_aw icon font in the headless snapshot test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/picker.rs | 142 +++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 29 deletions(-) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index ee98e49b4..732f86cd9 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -13,7 +13,7 @@ use iced::widget::{button, column, container, row, text, text_input, Space}; use iced::{Background, Border, Color, Element, Length, Theme}; const PANEL_WIDTH: f32 = 400.0; -const BODY_HEIGHT: f32 = 300.0; +const BODY_HEIGHT: f32 = 352.0; const CONTROL_HEIGHT: f32 = 28.0; const SWATCH_WIDTH: f32 = 104.0; @@ -168,9 +168,7 @@ impl ColorPicker { ColorPickerMsg::Hue(h) => self.set_hsb(Hsb { h, ..self.hsb }), ColorPickerMsg::Saturation(s) => self.set_hsb(Hsb { s, ..self.hsb }), ColorPickerMsg::Brightness(b) => self.set_hsb(Hsb { b, ..self.hsb }), - ColorPickerMsg::SaturationBrightness(s, b) => { - self.set_hsb(Hsb { s, b, ..self.hsb }) - } + ColorPickerMsg::SaturationBrightness(s, b) => self.set_hsb(Hsb { s, b, ..self.hsb }), ColorPickerMsg::Red(red) => { let (_, g, b) = self.rgb(); self.set_rgb(red, g, b); @@ -236,7 +234,9 @@ impl ColorPicker { .width(12) .height(12) .style(move |theme: &Theme| swatch_style(theme, rgb_color(sr, sg, sb))), - text(math::describe(self.pending)).size(11).font(iced::Font::MONOSPACE), + text(math::describe(self.pending)) + .size(11) + .font(iced::Font::MONOSPACE), ] .spacing(8) .align_y(iced::Center); @@ -249,9 +249,7 @@ impl ColorPicker { .width(Length::Fill) .height(1) .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.weak.color - )), + background: Some(Background::Color(theme.palette().background.weak.color)), ..Default::default() }), info, @@ -372,7 +370,15 @@ impl ColorPicker { background: Some(Background::Color(new_bg)), ..Default::default() }); - let swatch = container(row![current, new]) + let divider = + container(Space::new()) + .width(1) + .height(Length::Fill) + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.35))), + ..Default::default() + }); + let swatch = container(row![current, divider, new]) .width(SWATCH_WIDTH) .height(CONTROL_HEIGHT) .padding(1) @@ -537,10 +543,7 @@ impl ColorPicker { fn hsb_sliders(&self) -> Element<'_, Message> { let Hsb { h, s, b } = self.hsb; let hue_stops = hue_stops(); - let sat_stops = vec![ - (0.0, hsb_color(h, 0.0, b)), - (1.0, hsb_color(h, 1.0, b)), - ]; + let sat_stops = vec![(0.0, hsb_color(h, 0.0, b)), (1.0, hsb_color(h, 1.0, b))]; let bright_stops = vec![(0.0, Color::BLACK), (1.0, hsb_color(h, s, 1.0))]; column![ slider_row( @@ -580,7 +583,10 @@ impl ColorPicker { fn rgb_sliders(&self) -> Element<'_, Message> { let (r, g, b) = self.rgb(); let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| { - vec![(0.0, rgb_color(lo.0, lo.1, lo.2)), (1.0, rgb_color(hi.0, hi.1, hi.2))] + vec![ + (0.0, rgb_color(lo.0, lo.1, lo.2)), + (1.0, rgb_color(hi.0, hi.1, hi.2)), + ] }; let channel = |value: u8| f32::from(value) / 255.0; let byte = |v: f32| (v * 255.0).round() as u8; @@ -591,7 +597,9 @@ impl ColorPicker { picker_msg(ColorPickerMsg::Red(byte(v))) }) .wheel_step(1.0 / 255.0), - number_box(u16::from(r), 255, |v| picker_msg(ColorPickerMsg::Red(v as u8))), + number_box(u16::from(r), 255, |v| picker_msg(ColorPickerMsg::Red( + v as u8 + ))), "", ), slider_row( @@ -600,7 +608,9 @@ impl ColorPicker { picker_msg(ColorPickerMsg::Green(byte(v))) }) .wheel_step(1.0 / 255.0), - number_box(u16::from(g), 255, |v| picker_msg(ColorPickerMsg::Green(v as u8))), + number_box(u16::from(g), 255, |v| picker_msg(ColorPickerMsg::Green( + v as u8 + ))), "", ), slider_row( @@ -609,7 +619,9 @@ impl ColorPicker { picker_msg(ColorPickerMsg::Blue(byte(v))) }) .wheel_step(1.0 / 255.0), - number_box(u16::from(b), 255, |v| picker_msg(ColorPickerMsg::Blue(v as u8))), + number_box(u16::from(b), 255, |v| picker_msg(ColorPickerMsg::Blue( + v as u8 + ))), "", ), ] @@ -643,9 +655,9 @@ impl ColorPicker { let values = row![ field( "H", - number_box(h.round() as u16, 360, |v| picker_msg(ColorPickerMsg::Hue(f32::from( - v - )))), + number_box(h.round() as u16, 360, |v| picker_msg(ColorPickerMsg::Hue( + f32::from(v) + ))), "°", ), Space::new().width(Length::Fill), @@ -795,7 +807,21 @@ fn number_box( .set_size(12.0) .font(iced::Font::MONOSPACE) .width(Length::Fixed(64.0)) + // Extra right padding keeps the digits clear of the arrows; padding + // below 5 would make NumberInput swap its arrows for + / − buttons. + .padding(iced::Padding { + top: 5.0, + right: 18.0, + bottom: 5.0, + left: 6.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), + }, + ) .into() } @@ -972,7 +998,14 @@ mod tests { p.update(ColorPickerMsg::Hue(120.0)); assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 255, b: 0 }); p.update(ColorPickerMsg::Red(10)); - assert_eq!(p.pending(), AcadColor::Rgb { r: 10, g: 255, b: 0 }); + assert_eq!( + p.pending(), + AcadColor::Rgb { + r: 10, + g: 255, + b: 0 + } + ); } #[test] @@ -1027,8 +1060,11 @@ mod tests { fn simulator(picker: &ColorPicker) -> iced_test::simulator::Simulator<'_, Message> { iced_test::simulator::Simulator::with_size( - iced::Settings::default(), - (PANEL_WIDTH + 24.0, 440.0), + 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), ) } @@ -1076,12 +1112,60 @@ mod tests { fn every_view_renders() { let folder = std::env::var_os("OCS_COLOR_PICKER_SNAPSHOTS").map(std::path::PathBuf::from); let views: [(&str, AcadColor, ColorPickerTab, TrueColorMode, bool); 6] = [ - ("index", AcadColor::Index(140), ColorPickerTab::Index, TrueColorMode::Hsb, false), - ("varies", AcadColor::Index(30), ColorPickerTab::Index, TrueColorMode::Hsb, true), - ("hsb", AcadColor::Rgb { r: 57, g: 191, b: 252 }, ColorPickerTab::TrueColor, TrueColorMode::Hsb, false), - ("rgb", AcadColor::Rgb { r: 57, g: 191, b: 252 }, ColorPickerTab::TrueColor, TrueColorMode::Rgb, false), - ("gradient", AcadColor::Rgb { r: 57, g: 191, b: 252 }, ColorPickerTab::TrueColor, TrueColorMode::Gradient, false), - ("exact", AcadColor::Rgb { r: 255, g: 0, b: 0 }, ColorPickerTab::TrueColor, TrueColorMode::Hsb, false), + ( + "index", + AcadColor::Index(140), + ColorPickerTab::Index, + TrueColorMode::Hsb, + false, + ), + ( + "varies", + AcadColor::Index(30), + ColorPickerTab::Index, + TrueColorMode::Hsb, + true, + ), + ( + "hsb", + AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }, + ColorPickerTab::TrueColor, + TrueColorMode::Hsb, + false, + ), + ( + "rgb", + AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }, + ColorPickerTab::TrueColor, + TrueColorMode::Rgb, + false, + ), + ( + "gradient", + AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }, + ColorPickerTab::TrueColor, + TrueColorMode::Gradient, + false, + ), + ( + "exact", + AcadColor::Rgb { r: 255, g: 0, b: 0 }, + ColorPickerTab::TrueColor, + TrueColorMode::Hsb, + false, + ), ]; for theme in [ crate::ui::style::fusion_theme::fusion_black(), From 3d7db54c23e77b2cf0982c149a140eb7e8e057be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:55:38 +0000 Subject: [PATCH 03/32] =?UTF-8?q?Show=20=C2=B0=20and=20%=20inside=20the=20?= =?UTF-8?q?Select=20Color=20number=20boxes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draw the unit as the NumberInput icon on the right of the digits instead of a separate label beside the box. The unit is not part of the value, so users still only type the number. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/picker.rs | 78 +++++++++++++++++------------------ 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 732f86cd9..f30221c26 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -552,28 +552,25 @@ impl ColorPicker { v * 360.0 ))) .wheel_step(1.0 / 360.0), - number_box(h.round() as u16, 360, |v| { + number_box(h.round() as u16, 360, Some('°'), |v| { picker_msg(ColorPickerMsg::Hue(f32::from(v))) }), - "°", ), slider_row( "S", GradientSlider::new(s, sat_stops, |v| picker_msg(ColorPickerMsg::Saturation(v))), - number_box((s * 100.0).round() as u16, 100, |v| { + number_box((s * 100.0).round() as u16, 100, Some('%'), |v| { picker_msg(ColorPickerMsg::Saturation(f32::from(v) / 100.0)) }), - "%", ), slider_row( "B", GradientSlider::new(b, bright_stops, |v| picker_msg(ColorPickerMsg::Brightness( v ))), - number_box((b * 100.0).round() as u16, 100, |v| { + number_box((b * 100.0).round() as u16, 100, Some('%'), |v| { picker_msg(ColorPickerMsg::Brightness(f32::from(v) / 100.0)) }), - "%", ), ] .spacing(10) @@ -597,10 +594,9 @@ impl ColorPicker { picker_msg(ColorPickerMsg::Red(byte(v))) }) .wheel_step(1.0 / 255.0), - number_box(u16::from(r), 255, |v| picker_msg(ColorPickerMsg::Red( - v as u8 - ))), - "", + number_box(u16::from(r), 255, None, |v| picker_msg( + ColorPickerMsg::Red(v as u8) + )), ), slider_row( "G", @@ -608,10 +604,9 @@ impl ColorPicker { picker_msg(ColorPickerMsg::Green(byte(v))) }) .wheel_step(1.0 / 255.0), - number_box(u16::from(g), 255, |v| picker_msg(ColorPickerMsg::Green( - v as u8 - ))), - "", + number_box(u16::from(g), 255, None, |v| picker_msg( + ColorPickerMsg::Green(v as u8) + )), ), slider_row( "B", @@ -619,10 +614,9 @@ impl ColorPicker { picker_msg(ColorPickerMsg::Blue(byte(v))) }) .wheel_step(1.0 / 255.0), - number_box(u16::from(b), 255, |v| picker_msg(ColorPickerMsg::Blue( - v as u8 - ))), - "", + number_box(u16::from(b), 255, None, |v| picker_msg( + ColorPickerMsg::Blue(v as u8) + )), ), ] .spacing(10) @@ -643,38 +637,31 @@ impl ColorPicker { .height(Length::Fixed(118.0)) .wheel_step(1.0 / 360.0); - let field = |letter: &'static str, input: Element<'static, Message>, unit: &'static str| { - row![ - text(letter).size(12).font(iced::Font::MONOSPACE), - input, - text(unit).size(11).style(muted_text), - ] - .spacing(4) - .align_y(iced::Center) + let field = |letter: &'static str, input: Element<'static, Message>| { + row![text(letter).size(12).font(iced::Font::MONOSPACE), input] + .spacing(4) + .align_y(iced::Center) }; let values = row![ field( "H", - number_box(h.round() as u16, 360, |v| picker_msg(ColorPickerMsg::Hue( - f32::from(v) - ))), - "°", + number_box(h.round() as u16, 360, Some('°'), |v| picker_msg( + ColorPickerMsg::Hue(f32::from(v)) + )), ), Space::new().width(Length::Fill), field( "S", - number_box((s * 100.0).round() as u16, 100, |v| { + number_box((s * 100.0).round() as u16, 100, Some('%'), |v| { picker_msg(ColorPickerMsg::Saturation(f32::from(v) / 100.0)) }), - "%", ), Space::new().width(Length::Fill), field( "B", - number_box((b * 100.0).round() as u16, 100, |v| { + number_box((b * 100.0).round() as u16, 100, Some('%'), |v| { picker_msg(ColorPickerMsg::Brightness(f32::from(v) / 100.0)) }), - "%", ), ] .align_y(iced::Center); @@ -781,7 +768,6 @@ fn slider_row<'a>( letter: &'static str, slider: GradientSlider<'a, Message>, number: Element<'a, Message>, - unit: &'static str, ) -> Element<'a, Message> { row![ container(text(letter).size(12).font(iced::Font::MONOSPACE)) @@ -789,7 +775,6 @@ fn slider_row<'a>( .align_x(iced::Center), slider, number, - container(text(unit).size(11).style(muted_text)).width(10), ] .spacing(8) .align_y(iced::Center) @@ -801,12 +786,13 @@ fn slider_row<'a>( fn number_box( value: u16, max: u16, + unit: Option, on_change: impl Fn(u16) -> Message + Clone + 'static, ) -> Element<'static, Message> { - iced_aw::widget::NumberInput::new(&value, 0..=max, on_change) + let input = iced_aw::widget::NumberInput::new(&value, 0..=max, on_change) .set_size(12.0) .font(iced::Font::MONOSPACE) - .width(Length::Fixed(64.0)) + .width(Length::Fixed(68.0)) // Extra right padding keeps the digits clear of the arrows; padding // below 5 would make NumberInput swap its arrows for + / − buttons. .padding(iced::Padding { @@ -821,8 +807,20 @@ fn number_box( button_background: None, icon_color: theme.palette().background.base.text.scale_alpha(0.75), }, - ) - .into() + ); + // The unit is drawn inside the box, after the digits, but is not part + // of the value: the user only ever types the number. + let input = 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, + }), + None => input, + }; + input.into() } fn hue_stops() -> Vec<(f32, Color)> { From cefa25882380b9bba95283d7bb5cbd37701c429e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:56:52 +0000 Subject: [PATCH 04/32] Share the colour editor, add an eyedropper, use library code - ColorEditor: the HSB / RGB / field editor and hex box, now shared by the Select Color panel and the Options background swatches. Those swatches open the panel without tabs or index colours, which removes the iced_aw color_picker feature and the BgPicker messages. - Sliders are iced's built-in slider and vertical_slider over a gradient container; the custom GradientSlider widget is gone. - Nearest index colour comes from opencadcodec's aci_table::nearest_aci. - Eyedropper: a pipette at the right of the mode row hides the panel, captures the window with iced's screenshot task and shows an 11x11 magnifier beside a crosshair; a click picks the pixel, Esc backs out. Not offered on the web build. - The hex box applies six-digit values while typing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- Cargo.toml | 2 +- assets/icons/ui/eyedropper.svg | 1 + src/app/mod.rs | 14 +- src/app/update/mod.rs | 52 +- src/app/update/style.rs | 24 +- src/app/view/mod.rs | 5 + src/app/view/modal.rs | 1 - src/ui/color_select/editor.rs | 484 ++++++++ src/ui/color_select/eyedropper.rs | 185 +++ src/ui/color_select/math.rs | 29 +- src/ui/color_select/mod.rs | 6 +- src/ui/color_select/picker.rs | 1012 ++++++----------- .../color_select/widgets/gradient_slider.rs | 319 ------ src/ui/color_select/widgets/mod.rs | 2 - src/ui/icons.rs | 1 + src/ui/window/options.rs | 41 +- 16 files changed, 1125 insertions(+), 1053 deletions(-) create mode 100644 assets/icons/ui/eyedropper.svg create mode 100644 src/ui/color_select/editor.rs create mode 100644 src/ui/color_select/eyedropper.rs delete mode 100644 src/ui/color_select/widgets/gradient_slider.rs diff --git a/Cargo.toml b/Cargo.toml index 89d31c359..19056d1c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,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", "number_input"] } +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/icons/ui/eyedropper.svg b/assets/icons/ui/eyedropper.svg new file mode 100644 index 000000000..fe34cd3f9 --- /dev/null +++ b/assets/icons/ui/eyedropper.svg @@ -0,0 +1 @@ + diff --git a/src/app/mod.rs b/src/app/mod.rs index 1ec39933d..1c9cacc71 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -838,9 +838,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, /// Which panel is currently floated at full height (hovered, or a pinned @@ -1580,7 +1577,7 @@ pub struct SaveOutcome { refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } -/// Which viewport background a colour-wheel session is editing. +/// Which viewport background the interface colour picker is editing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BgTarget { Model, @@ -1610,6 +1607,8 @@ pub enum ColorPickTarget { LayerState(usize), /// The MText editor's selection (or global) colour. MText, + /// An Options viewport background (interface colour, true colour only). + Background(BgTarget), } /// Table records the clipboard entities depend on, snapshotted from the source @@ -2311,12 +2310,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). @@ -4283,7 +4276,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(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a65093e5d..14af0ca72 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8256,39 +8256,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() { @@ -11066,6 +11033,16 @@ impl OpenCADStudio { self.color_picker = None; Task::none() } + Some(crate::ui::color_select::Outcome::Screenshot) => { + use crate::ui::color_select::{eyedropper::EyedropperMsg, ColorPickerMsg}; + self.main_window.map_or_else(Task::none, |id| { + iced::window::screenshot(id).map(|shot| { + Message::ColorPicker(ColorPickerMsg::Eyedropper( + EyedropperMsg::Shot(shot), + )) + }) + }) + } None => Task::none(), } } @@ -11075,7 +11052,14 @@ impl OpenCADStudio { } Message::ColorWindowPick(color) => { - self.note_recent_color(color); + // 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::Background(_)) + ) { + self.note_recent_color(color); + } self.on_color_window_pick(color) } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 97994f40c..0db169c56 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1136,6 +1136,25 @@ impl OpenCADStudio { } pub(super) fn on_color_window_pick(&mut self, color: codec::types::Color) -> Task { + if let Some(crate::app::ColorPickTarget::Background(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, + // persistence or the MatchTheme fallback. + let hex = crate::app::config::rgb_to_hex([r, g, b]); + return Task::done(match target { + crate::app::BgTarget::Model => Message::ModelSpaceBgChanged(hex), + crate::app::BgTarget::Paper => Message::PaperSpaceBgChanged(hex), + crate::app::BgTarget::Desk => Message::DeskSpaceBgChanged(hex), + }); + } if matches!( self.color_picker.as_ref().map(|picker| &picker.target), Some(crate::app::ColorPickTarget::PlotStyle) @@ -1187,7 +1206,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::Background(_), + ) => None, None => None, }; if let Some(m) = edit { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 19dbec8ab..1f796a161 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2475,6 +2475,11 @@ bg={bg_ms:.1}ms n={view_count}" } 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"), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 5cf0dbbd4..e3add1285 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -380,7 +380,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/ui/color_select/editor.rs b/src/ui/color_select/editor.rs new file mode 100644 index 000000000..770fbfcc6 --- /dev/null +++ b/src/ui/color_select/editor.rs @@ -0,0 +1,484 @@ +//! The colour editor: HSB or RGB sliders, or 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 FIELD_HEIGHT: f32 = 118.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EditorMode { + #[default] + Hsb, + Rgb, + Field, +} + +#[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), + HexInput(String), + HexSubmit, +} + +pub struct ColorEditor { + mode: EditorMode, + /// Kept beside `rgb` so hue and saturation survive grays and black. + hsb: Hsb, + 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, + }, + rgb, + hex_draft: None, + }; + editor.set_rgb(rgb); + editor + } + + pub fn rgb(&self) -> (u8, u8, u8) { + self.rgb + } + + pub fn mode(&self) -> EditorMode { + self.mode + } + + 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.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.hex_draft = None; + } + + /// 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::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). + pub fn view<'a>( + &'a self, + mode_end: Option>, + hex_end: Option>, + ) -> Element<'a, Message> { + let Hsb { h, s, b } = self.hsb; + let (r, g, bl) = self.rgb; + + let mode_button = |content: Element<'a, Message>, mode: EditorMode| { + let active = self.mode == mode; + button(container(content).center_y(Length::Fill)) + .on_press(msg(EditorMsg::Mode(mode))) + .height(28) + .padding([0, 10]) + .style(move |theme: &Theme, status| { + super::picker::toggle_style(theme, active, status) + }) + }; + let field_icon = container(Space::new()) + .width(26) + .height(16) + .style(|_theme: &Theme| { + gradient_box( + 3.0 * std::f32::consts::FRAC_PI_4, + &[ + (0.0, Color::WHITE), + (0.5, Color::from_rgb8(0x3A, 0xA0, 0xFF)), + (1.0, Color::BLACK), + ], + ) + }); + let mut modes = row![ + mode_button(text("HSB").size(12).into(), EditorMode::Hsb), + mode_button(text("RGB").size(12).into(), EditorMode::Rgb), + mode_button(field_icon.into(), EditorMode::Field), + Space::new().width(Length::Fill), + ] + .spacing(6); + if let Some(end) = mode_end { + modes = modes.push(end); + } + + let editor: Element<'a, Message> = match self.mode { + EditorMode::Hsb => 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, + ), + ] + .spacing(10) + .into(), + 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) + ), + ] + .spacing(10) + .into() + } + EditorMode::Field => { + 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(Length::Fixed(FIELD_HEIGHT)); + // A vertical slider grows upwards; the strip runs red at the top. + let strip = stack![ + container(Space::new()) + .width(STRIP_WIDTH) + .height(FIELD_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(FIELD_HEIGHT) + .style(handle_style), + ]; + let value = |letter: &'static str, + v: f32, + max: f32, + unit: char, + f: fn(f32) -> EditorMsg| { + row![ + text(letter).size(12).font(iced::Font::MONOSPACE), + number_box(v, max, Some(unit), f), + ] + .spacing(4) + .align_y(iced::Center) + }; + column![ + row![area, strip].spacing(12), + row![ + value("H", h, 360.0, '°', EditorMsg::Hue), + Space::new().width(Length::Fill), + value("S", s * 100.0, 100.0, '%', EditorMsg::Saturation), + Space::new().width(Length::Fill), + value("B", b * 100.0, 100.0, '%', EditorMsg::Brightness), + ] + .align_y(iced::Center), + ] + .spacing(10) + .into() + } + }; + + let hex = self + .hex_draft + .clone() + .unwrap_or_else(|| math::hex_string(r, g, bl)); + 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, 8]) + .width(Length::Fixed(92.0)), + Space::new().width(Length::Fill), + ] + .spacing(8) + .align_y(iced::Center); + if let Some(end) = hex_end { + hex_row = hex_row.push(end); + } + + column![modes, editor, hex_row].spacing(12).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(68.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: 6.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..87c839356 --- /dev/null +++ b/src/ui/color_select/eyedropper.rs @@ -0,0 +1,185 @@ +//! 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::{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, + Cancel, +} + +#[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); + 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; + let x = if cursor.x + OFFSET + size > window_w { + cursor.x - OFFSET - size + } else { + cursor.x + OFFSET + }; + let y = if cursor.y + OFFSET + size + 20.0 > window_h { + cursor.y - OFFSET - size - 20.0 + } else { + cursor.y + OFFSET + }; + stack![catcher, pin(loupe).x(x).y(y)].into() + } +} + +fn rgb((r, g, b): (u8, u8, u8)) -> Color { + Color::from_rgb8(r, g, b) +} + +/// 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 index a2b6b9c0e..4a01eff3a 100644 --- a/src/ui/color_select/math.rs +++ b/src/ui/color_select/math.rs @@ -73,24 +73,9 @@ pub fn color_rgb(color: AcadColor) -> Option<(u8, u8, u8)> { } } -/// The closest index colour to an RGB value, and whether it is an exact match. -pub fn nearest_aci_match(r: u8, g: u8, b: u8) -> (u8, bool) { - let mut best = 7; - let mut best_distance = u32::MAX; - for index in 1..=255 { - let Some((ar, ag, ab)) = aci_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, best_distance == 0) +/// 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. @@ -222,11 +207,9 @@ mod tests { } #[test] - fn nearest_aci_reports_exact_matches() { - assert_eq!(nearest_aci_match(255, 0, 0), (1, true)); - let (index, exact) = nearest_aci_match(250, 5, 5); - assert_eq!(index, 1); - assert!(!exact); + 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] diff --git a/src/ui/color_select/mod.rs b/src/ui/color_select/mod.rs index 5825e4d56..591c1137f 100644 --- a/src/ui/color_select/mod.rs +++ b/src/ui/color_select/mod.rs @@ -3,11 +3,13 @@ //! properties panel and every style editor so colour selection looks and //! behaves the same everywhere. +pub mod editor; +pub mod eyedropper; pub mod math; pub mod picker; pub mod widgets; -pub use picker::{ColorPicker, ColorPickerMsg, ColorPickerTab, Outcome, TrueColorMode}; +pub use picker::{ColorPicker, ColorPickerMsg, ColorPickerTab, Outcome}; use crate::app::Message; use crate::t; @@ -42,7 +44,7 @@ pub fn color_to_aci_string(c: AcadColor) -> String { /// Return the closest CAD Color Index for an RGB colour. pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 { - math::nearest_aci_match(r, g, b).0 + math::nearest_aci(r, g, b) } /// Decode an ACI integer string back into an `AcadColor`. diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index f30221c26..bc13a55bf 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -2,9 +2,12 @@ //! //! 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 (an Options background) it shows only the +//! colour editor: no tabs, no index colours. -use super::math::{self, Hsb}; -use super::widgets::{ColorArea, GradientSlider}; +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; @@ -14,7 +17,8 @@ use iced::{Background, Border, Color, Element, Length, Theme}; const PANEL_WIDTH: f32 = 400.0; const BODY_HEIGHT: f32 = 352.0; -const CONTROL_HEIGHT: f32 = 28.0; +/// The interface picker has no tabs or info line, only the editor. +const EDITOR_ONLY_HEIGHT: f32 = 258.0; const SWATCH_WIDTH: f32 = 104.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -24,36 +28,20 @@ pub enum ColorPickerTab { TrueColor, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum TrueColorMode { - #[default] - Hsb, - Rgb, - Gradient, -} - #[derive(Debug, Clone)] pub enum ColorPickerMsg { Tab(ColorPickerTab), - Mode(TrueColorMode), /// A swatch was clicked. Pick(AcadColor), /// Set the new colour back to the current one. Revert, - Hue(f32), - Saturation(f32), - Brightness(f32), - SaturationBrightness(f32, f32), - Red(u8), - Green(u8), - Blue(u8), + Editor(EditorMsg), FieldInput(String), FieldSubmit, - HexInput(String), - HexSubmit, /// Replace the true colour with its nearest index colour. UseNearest, Arrow(ArrowKey), + Eyedropper(EyedropperMsg), Ok, Cancel, } @@ -63,6 +51,8 @@ pub enum ColorPickerMsg { pub enum Outcome { Commit(AcadColor), Cancel, + /// Capture the window for the eyedropper. + Screenshot, } pub struct ColorPicker { @@ -73,11 +63,9 @@ pub struct ColorPicker { varies: bool, pending: AcadColor, tab: ColorPickerTab, - mode: TrueColorMode, - /// Kept separately from `pending` so the hue survives grays and black. - hsb: Hsb, + editor: ColorEditor, field_draft: Option, - hex_draft: Option, + eyedropper: Option, } impl ColorPicker { @@ -87,23 +75,16 @@ impl ColorPicker { } else { ColorPickerTab::Index }; - let mut picker = Self { + Self { target, current, varies, pending: current, tab, - mode: TrueColorMode::default(), - hsb: Hsb { - h: 0.0, - s: 0.0, - b: 1.0, - }, + editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))), field_draft: None, - hex_draft: None, - }; - picker.set_pending(current); - picker + eyedropper: None, + } } pub fn pending(&self) -> AcadColor { @@ -114,107 +95,85 @@ impl ColorPicker { self.tab } - pub fn mode(&self) -> TrueColorMode { - self.mode + /// Interface colours (Options backgrounds) only get the colour editor. + fn editor_only(&self) -> bool { + matches!(self.target, ColorPickTarget::Background(_)) } fn set_pending(&mut self, color: AcadColor) { self.pending = color; self.field_draft = None; - self.hex_draft = None; - if let Some((r, g, b)) = math::color_rgb(color) { - let next = math::rgb_to_hsb(r, g, b); - self.hsb = Hsb { - // A gray or black has no hue of its own; keep the last one so - // the sliders and field don't jump back to red. - h: if next.s == 0.0 || next.b == 0.0 { - self.hsb.h - } else { - next.h - }, - s: if next.b == 0.0 { self.hsb.s } else { next.s }, - b: next.b, - }; + if let Some(rgb) = math::color_rgb(color) { + self.editor.set_rgb(rgb); } } - 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), - }; - let (r, g, b) = self.hsb.to_rgb(); - self.pending = AcadColor::Rgb { r, g, b }; - self.field_draft = None; - self.hex_draft = None; - } - - fn set_rgb(&mut self, r: u8, g: u8, b: u8) { - self.set_pending(AcadColor::Rgb { r, g, b }); - } - - /// RGB of the pending colour; logical colours fall back to the sliders. fn rgb(&self) -> (u8, u8, u8) { - math::color_rgb(self.pending).unwrap_or_else(|| self.hsb.to_rgb()) + 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 }); + } + } + ColorPickerMsg::Eyedropper(EyedropperMsg::Cancel) | ColorPickerMsg::Cancel => { + self.eyedropper = None; + } + _ => {} + } + return None; + } match msg { ColorPickerMsg::Tab(tab) => self.tab = tab, - ColorPickerMsg::Mode(mode) => self.mode = mode, ColorPickerMsg::Pick(color) => self.set_pending(color), ColorPickerMsg::Revert => self.set_pending(self.current), - ColorPickerMsg::Hue(h) => self.set_hsb(Hsb { h, ..self.hsb }), - ColorPickerMsg::Saturation(s) => self.set_hsb(Hsb { s, ..self.hsb }), - ColorPickerMsg::Brightness(b) => self.set_hsb(Hsb { b, ..self.hsb }), - ColorPickerMsg::SaturationBrightness(s, b) => self.set_hsb(Hsb { s, b, ..self.hsb }), - ColorPickerMsg::Red(red) => { - let (_, g, b) = self.rgb(); - self.set_rgb(red, g, b); - } - ColorPickerMsg::Green(green) => { - let (r, _, b) = self.rgb(); - self.set_rgb(r, green, b); - } - ColorPickerMsg::Blue(blue) => { - let (r, g, _) = self.rgb(); - self.set_rgb(r, g, blue); + ColorPickerMsg::Editor(editor) => { + if self.editor.update(editor) { + let (r, g, b) = self.editor.rgb(); + self.pending = AcadColor::Rgb { r, g, b }; + self.field_draft = None; + } } ColorPickerMsg::FieldInput(value) => self.field_draft = Some(value), ColorPickerMsg::FieldSubmit => { - if let Some(draft) = self.field_draft.take() { - if let Some(color) = math::parse_color_input(&draft) { - self.set_pending(color); - } - } - } - ColorPickerMsg::HexInput(value) => self.hex_draft = Some(value), - ColorPickerMsg::HexSubmit => { - if let Some(draft) = self.hex_draft.take() { - if let Some((r, g, b)) = math::parse_hex(&draft) { - self.set_rgb(r, g, b); - } + if let Some(color) = self + .field_draft + .take() + .and_then(|d| math::parse_color_input(&d)) + { + self.set_pending(color); } } ColorPickerMsg::UseNearest => { let (r, g, b) = self.rgb(); - let (index, _) = math::nearest_aci_match(r, g, b); - self.set_pending(AcadColor::Index(index)); + self.set_pending(AcadColor::Index(math::nearest_aci(r, g, b))); } ColorPickerMsg::Arrow(direction) => { - if self.tab == ColorPickerTab::Index { - if let AcadColor::Index(index) = self.pending { - if let Some(next) = step_index(index, direction) { - self.set_pending(AcadColor::Index(next)); - } + if let (ColorPickerTab::Index, AcadColor::Index(index)) = (self.tab, self.pending) { + if let Some(next) = step_index(index, direction) { + self.set_pending(AcadColor::Index(next)); } } } + 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::FieldSubmit); - self.update(ColorPickerMsg::HexSubmit); + self.update(ColorPickerMsg::Editor(EditorMsg::HexSubmit)); return Some(Outcome::Commit(self.pending)); } ColorPickerMsg::Cancel => return Some(Outcome::Cancel), @@ -222,55 +181,53 @@ impl ColorPicker { None } + /// 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 body = match self.tab { - ColorPickerTab::Index => self.index_tab(), - ColorPickerTab::TrueColor => self.true_color_tab(), + let editor_only = self.editor_only(); + let body = if editor_only || self.tab == ColorPickerTab::TrueColor { + self.true_color_tab() + } else { + self.index_tab() }; - let (sr, sg, sb) = math::color_rgb(self.pending).unwrap_or((128, 128, 128)); - let info = row![ - container(Space::new()) - .width(12) - .height(12) - .style(move |theme: &Theme| swatch_style(theme, rgb_color(sr, sg, sb))), - text(math::describe(self.pending)) - .size(11) - .font(iced::Font::MONOSPACE), - ] - .spacing(8) - .align_y(iced::Center); - - let body = container( - column![ - body, - Space::new().height(Length::Fill), - container(Space::new()) - .width(Length::Fill) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weak.color)), - ..Default::default() - }), - info, - ] - .spacing(8), - ) - .padding(12) - .width(Length::Fill) - .height(Length::Fixed(BODY_HEIGHT)) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weakest.color)), - border: Border { - color: palette.background.weak.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() - } - }); + let mut body = column![body, Space::new().height(Length::Fill)].spacing(8); + if !editor_only { + let fill = + math::color_rgb(self.pending).map_or(Color::from_rgb8(128, 128, 128), rgb_color); + body = body + .push( + container(Space::new()) + .width(Length::Fill) + .height(1) + .style(|theme: &Theme| filled(theme.palette().background.weak.color)), + ) + .push( + row![ + container(Space::new()) + .width(12) + .height(12) + .style(move |_theme: &Theme| swatch_style(fill)), + text(math::describe(self.pending)) + .size(11) + .font(iced::Font::MONOSPACE), + ] + .spacing(8) + .align_y(iced::Center), + ); + } + let body = container(body) + .padding(12) + .width(Length::Fill) + .height(if editor_only { + EDITOR_ONLY_HEIGHT + } else { + BODY_HEIGHT + }) + .style(panel_style); let actions = row![ Space::new().width(Length::Fill), @@ -285,24 +242,25 @@ impl ColorPicker { ] .spacing(8); - column![self.tabs(), self.preview_row(), body, actions] - .spacing(12) - .width(Length::Fixed(PANEL_WIDTH)) + let mut panel = column![].spacing(12).width(PANEL_WIDTH); + if !editor_only { + panel = panel.push(self.tabs()); + } + panel + .push(self.preview_row()) + .push(body) + .push(actions) .into() } fn tabs(&self) -> Element<'_, Message> { let tab = |label: String, tab: ColorPickerTab| { let active = self.tab == tab; - button( - container(text(label).size(12)) - .width(Length::Fill) - .align_x(iced::Center), - ) - .on_press(picker_msg(ColorPickerMsg::Tab(tab))) - .width(Length::Fill) - .padding([5, 4]) - .style(move |theme: &Theme, status| segment_style(theme, active, status)) + 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![ @@ -312,31 +270,12 @@ impl ColorPicker { .spacing(2), ) .padding(2) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weakest.color)), - border: Border { - color: palette.background.weak.color, - width: 1.0, - radius: 7.0.into(), - }, - ..Default::default() - } - }) + .style(panel_style) .into() } fn preview_row(&self) -> Element<'_, Message> { let caption = |label: String| text(label).size(11).style(muted_text); - - let (current_bg, _) = acad_color_display(self.current); - let (new_bg, _) = acad_color_display(self.pending); - let current_label = if self.varies { - "*VARIES*".to_string() - } else { - logical_badge(self.current) - }; let swatch_text = |label: String, background: Color| { let ink = if ink_is_dark(background) { Color::from_rgb8(0x11, 0x12, 0x14) @@ -354,33 +293,33 @@ impl ColorPicker { ) .center(Length::Fill) }; + let (current_bg, _) = acad_color_display(self.current); + let (new_bg, _) = acad_color_display(self.pending); + let current_label = if self.varies { + "*VARIES*".to_string() + } else { + logical_badge(self.current) + }; let current = button(swatch_text(current_label, 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(Background::Color(current_bg)), + background: Some(current_bg.into()), ..Default::default() }); let new = container(swatch_text(logical_badge(self.pending), new_bg)) .width(Length::Fill) .height(Length::Fill) - .style(move |_theme: &Theme| container::Style { - background: Some(Background::Color(new_bg)), - ..Default::default() - }); - let divider = - container(Space::new()) - .width(1) - .height(Length::Fill) - .style(|_theme: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.35))), - ..Default::default() - }); + .style(move |_theme: &Theme| filled(new_bg)); + 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 swatch = container(row![current, divider, new]) .width(SWATCH_WIDTH) - .height(CONTROL_HEIGHT) + .height(28) .padding(1) .style(|theme: &Theme| container::Style { border: Border { @@ -392,93 +331,84 @@ impl ColorPicker { }) .clip(true); - let field_text = self - .field_draft - .clone() - .unwrap_or_else(|| math::color_field_text(self.pending)); - let field = text_input("", &field_text) - .on_input(|value| picker_msg(ColorPickerMsg::FieldInput(value))) - .on_submit(picker_msg(ColorPickerMsg::FieldSubmit)) - .size(12) - .font(iced::Font::MONOSPACE) - .padding([6, 10]) - .width(Length::Fill); - - row![ - column![ - row![ - caption(t!("Current").into_owned()), - Space::new().width(Length::Fill), - caption(t!("New").into_owned()), - ] - .width(SWATCH_WIDTH), - swatch, + let mut preview = row![column![ + row![ + caption(t!("Current").into_owned()), + Space::new().width(Length::Fill), + caption(t!("New").into_owned()), ] - .spacing(4), - column![caption(t!("Color").into_owned()), field] + .width(SWATCH_WIDTH), + swatch, + ] + .spacing(4)] + .spacing(10) + .align_y(iced::Bottom); + if !self.editor_only() { + let field_text = self + .field_draft + .clone() + .unwrap_or_else(|| math::color_field_text(self.pending)); + preview = preview.push( + column![ + caption(t!("Color").into_owned()), + text_input("", &field_text) + .on_input(|value| picker_msg(ColorPickerMsg::FieldInput(value))) + .on_submit(picker_msg(ColorPickerMsg::FieldSubmit)) + .size(12) + .font(iced::Font::MONOSPACE) + .padding([6, 10]), + ] .spacing(4) .width(Length::Fill), - ] - .spacing(10) - .align_y(iced::Bottom) - .into() + ); + } + preview.into() } fn index_tab(&self) -> Element<'_, Message> { let section = |label: String| text(label).size(11); - - let mut standard = row![].spacing(5); - for index in 1u8..=9 { - standard = standard.push(self.swatch_button(index, 30.0, 26.0)); - } - + let swatches = |range: std::ops::RangeInclusive| { + range.fold(row![].spacing(5), |line, index| { + line.push(self.swatch_button(index, 30.0, 26.0)) + }) + }; // ACI 10–249 come in groups of ten: 24 hue families across, their ten // shades down. - let mut grid = column![].spacing(2); - for shade in 0u8..10 { - let mut line = row![].spacing(2); - for family in 1u8..=24 { - line = line.push(self.swatch_button(family * 10 + shade, 13.0, 13.0)); - } - grid = grid.push(line); - } - - let mut grays = row![].spacing(5); - for index in 250u8..=255 { - grays = grays.push(self.swatch_button(index, 30.0, 26.0)); - } - + let grid = (0u8..10).fold(column![].spacing(2), |grid, shade| { + grid.push((1u8..=24).fold(row![].spacing(2), |line, family| { + line.push(self.swatch_button(family * 10 + shade, 13.0, 13.0)) + })) + }); column![ - column![section(t!("Standard colors").into_owned()), standard].spacing(6), + column![section(t!("Standard colors").into_owned()), swatches(1..=9)].spacing(6), column![ - row![ - section(t!("CAD Color Index (ACI) 10–249").into_owned()), - Space::new().width(Length::Fill), - ], - grid, + 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), - column![section(t!("Grayscale 250–255").into_owned()), grays].spacing(6), ] .spacing(12) .into() } fn swatch_button(&self, index: u8, width: f32, height: f32) -> Element<'_, Message> { - let (r, g, b) = math::aci_rgb(index).unwrap_or((128, 128, 128)); - let fill = rgb_color(r, g, b); + let fill = rgb_color(math::aci_rgb(index).unwrap_or((128, 128, 128))); let selected = self.pending == AcadColor::Index(index); button(Space::new().width(width).height(height)) .on_press(picker_msg(ColorPickerMsg::Pick(AcadColor::Index(index)))) .padding(0) .style(move |theme: &Theme, status| { let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); - let accent = theme.palette().primary.base.color; button::Style { - background: Some(Background::Color(fill)), + background: Some(fill.into()), border: Border { color: if selected || hovered { - accent + theme.palette().primary.base.color } else { Color::from_rgba(0.0, 0.0, 0.0, 0.35) }, @@ -498,221 +428,50 @@ impl ColorPicker { } fn true_color_tab(&self) -> Element<'_, Message> { - let mode_button = |content: Element<'static, Message>, mode: TrueColorMode| { - let active = self.mode == mode; - button(container(content).center_y(Length::Fill)) - .on_press(picker_msg(ColorPickerMsg::Mode(mode))) - .height(CONTROL_HEIGHT) - .padding([0, 10]) - .style(move |theme: &Theme, status| toggle_style(theme, active, status)) - }; - let gradient_icon: Element<'static, Message> = container(Space::new()) - .width(26) - .height(16) - .style(|_theme: &Theme| container::Style { - background: Some(Background::Gradient(iced::Gradient::Linear( - iced::gradient::Linear::new(iced::Radians(std::f32::consts::FRAC_PI_4 * 3.0)) - .add_stop(0.0, Color::WHITE) - .add_stop(0.5, Color::from_rgb8(0x3A, 0xA0, 0xFF)) - .add_stop(1.0, Color::BLACK), - ))), - border: Border { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.3), - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }) - .into(); - let modes = row![ - mode_button(text("HSB").size(12).into(), TrueColorMode::Hsb), - mode_button(text("RGB").size(12).into(), TrueColorMode::Rgb), - mode_button(gradient_icon, TrueColorMode::Gradient), - ] - .spacing(6); - - let editor = match self.mode { - TrueColorMode::Hsb => self.hsb_sliders(), - TrueColorMode::Rgb => self.rgb_sliders(), - TrueColorMode::Gradient => self.gradient_editor(), - }; - - column![modes, editor, self.hex_row()].spacing(12).into() - } - - fn hsb_sliders(&self) -> Element<'_, Message> { - let Hsb { h, s, b } = self.hsb; - let hue_stops = hue_stops(); - let sat_stops = vec![(0.0, hsb_color(h, 0.0, b)), (1.0, hsb_color(h, 1.0, b))]; - let bright_stops = vec![(0.0, Color::BLACK), (1.0, hsb_color(h, s, 1.0))]; - column![ - slider_row( - "H", - GradientSlider::new(h / 360.0, hue_stops, |v| picker_msg(ColorPickerMsg::Hue( - v * 360.0 - ))) - .wheel_step(1.0 / 360.0), - number_box(h.round() as u16, 360, Some('°'), |v| { - picker_msg(ColorPickerMsg::Hue(f32::from(v))) - }), - ), - slider_row( - "S", - GradientSlider::new(s, sat_stops, |v| picker_msg(ColorPickerMsg::Saturation(v))), - number_box((s * 100.0).round() as u16, 100, Some('%'), |v| { - picker_msg(ColorPickerMsg::Saturation(f32::from(v) / 100.0)) - }), - ), - slider_row( - "B", - GradientSlider::new(b, bright_stops, |v| picker_msg(ColorPickerMsg::Brightness( - v - ))), - number_box((b * 100.0).round() as u16, 100, Some('%'), |v| { - picker_msg(ColorPickerMsg::Brightness(f32::from(v) / 100.0)) - }), - ), - ] - .spacing(10) - .into() - } - - fn rgb_sliders(&self) -> Element<'_, Message> { - let (r, g, b) = self.rgb(); - let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| { - vec![ - (0.0, rgb_color(lo.0, lo.1, lo.2)), - (1.0, rgb_color(hi.0, hi.1, hi.2)), - ] - }; - let channel = |value: u8| f32::from(value) / 255.0; - let byte = |v: f32| (v * 255.0).round() as u8; - column![ - slider_row( - "R", - GradientSlider::new(channel(r), ends((0, g, b), (255, g, b)), move |v| { - picker_msg(ColorPickerMsg::Red(byte(v))) - }) - .wheel_step(1.0 / 255.0), - number_box(u16::from(r), 255, None, |v| picker_msg( - ColorPickerMsg::Red(v as u8) - )), - ), - slider_row( - "G", - GradientSlider::new(channel(g), ends((r, 0, b), (r, 255, b)), move |v| { - picker_msg(ColorPickerMsg::Green(byte(v))) - }) - .wheel_step(1.0 / 255.0), - number_box(u16::from(g), 255, None, |v| picker_msg( - ColorPickerMsg::Green(v as u8) - )), - ), - slider_row( - "B", - GradientSlider::new(channel(b), ends((r, g, 0), (r, g, 255)), move |v| { - picker_msg(ColorPickerMsg::Blue(byte(v))) - }) - .wheel_step(1.0 / 255.0), - number_box(u16::from(b), 255, None, |v| picker_msg( - ColorPickerMsg::Blue(v as u8) - )), - ), - ] - .spacing(10) - .into() - } - - fn gradient_editor(&self) -> Element<'_, Message> { - let Hsb { h, s, b } = self.hsb; - let (r, g, bl) = self.rgb(); - let area = ColorArea::new(hsb_color(h, 1.0, 1.0), rgb_color(r, g, bl), s, b, |s, b| { - picker_msg(ColorPickerMsg::SaturationBrightness(s, b)) - }) - .height(Length::Fixed(118.0)); - let hue = GradientSlider::new(h / 360.0, hue_stops(), |v| { - picker_msg(ColorPickerMsg::Hue(v * 360.0)) - }) - .vertical() - .height(Length::Fixed(118.0)) - .wheel_step(1.0 / 360.0); - - let field = |letter: &'static str, input: Element<'static, Message>| { - row![text(letter).size(12).font(iced::Font::MONOSPACE), input] - .spacing(4) - .align_y(iced::Center) - }; - let values = row![ - field( - "H", - number_box(h.round() as u16, 360, Some('°'), |v| picker_msg( - ColorPickerMsg::Hue(f32::from(v)) - )), - ), - Space::new().width(Length::Fill), - field( - "S", - number_box((s * 100.0).round() as u16, 100, Some('%'), |v| { - picker_msg(ColorPickerMsg::Saturation(f32::from(v) / 100.0)) - }), - ), - Space::new().width(Length::Fill), - field( - "B", - number_box((b * 100.0).round() as u16, 100, Some('%'), |v| { - picker_msg(ColorPickerMsg::Brightness(f32::from(v) / 100.0)) - }), - ), - ] - .align_y(iced::Center); - - column![row![area, hue].spacing(12), values] - .spacing(10) - .into() - } - - fn hex_row(&self) -> Element<'_, Message> { - let (r, g, b) = self.rgb(); - let hex = self - .hex_draft - .clone() - .unwrap_or_else(|| math::hex_string(r, g, b)); - let (nearest, exact) = math::nearest_aci_match(r, g, b); - let is_index = matches!(self.pending, AcadColor::Index(_)); - let match_label = if exact && !is_index { - t!("Exact index match") + // 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 { - t!("Nearest index") + Some( + button(crate::ui::icons::themed(crate::ui::icons::EYEDROPPER, 16.0)) + .on_press(picker_msg(ColorPickerMsg::Eyedropper(EyedropperMsg::Start))) + .height(28) + .padding([0, 8]) + .style(|theme: &Theme, status| toggle_style(theme, false, status)) + .into(), + ) }; - let (nr, ng, nb) = math::aci_rgb(nearest).unwrap_or((128, 128, 128)); - let nearest_fill = rgb_color(nr, ng, nb); - let highlight = exact && !is_index; + let nearest = (!self.editor_only()).then(|| self.nearest_button()); + self.editor.view(pipette, nearest) + } + + 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(t!("Hex")).size(11).style(muted_text), - text_input("", &hex) - .on_input(|value| picker_msg(ColorPickerMsg::HexInput(value))) - .on_submit(picker_msg(ColorPickerMsg::HexSubmit)) - .size(12) - .font(iced::Font::MONOSPACE) - .padding([5, 8]) - .width(Length::Fixed(92.0)), - Space::new().width(Length::Fill), - text(match_label).size(11).style(move |theme: &Theme| { - let palette = theme.palette(); - text::Style { - color: Some(if highlight { - palette.success.base.color - } else { - palette.background.base.text.scale_alpha(0.65) - }), - } + 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(theme, nearest_fill)), + .style(move |_theme: &Theme| swatch_style(fill)), text(format!("ACI {nearest}")) .size(12) .font(iced::Font::MONOSPACE), @@ -722,7 +481,7 @@ impl ColorPicker { ) .on_press(picker_msg(ColorPickerMsg::UseNearest)) .padding([4, 8]) - .style(move |theme: &Theme, status| toggle_style(theme, highlight, status)), + .style(move |theme: &Theme, status| toggle_style(theme, exact, status)), ] .spacing(8) .align_y(iced::Center) @@ -734,15 +493,16 @@ impl ColorPicker { /// 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 => match direction { - ArrowKey::Left if index > 1 => Some(index - 1), - ArrowKey::Right if index < 9 => Some(index + 1), - _ => None, - }, + 1..=9 => along_row(1, 9), + 250..=255 => along_row(250, 255), 10..=249 => { - let family = index / 10; - let shade = index % 10; + 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), @@ -751,11 +511,6 @@ fn step_index(index: u8, direction: ArrowKey) -> Option { _ => None, } } - 250..=255 => match direction { - ArrowKey::Left if index > 250 => Some(index - 1), - ArrowKey::Right if index < 255 => Some(index + 1), - _ => None, - }, _ => None, } } @@ -764,89 +519,9 @@ fn picker_msg(msg: ColorPickerMsg) -> Message { Message::ColorPicker(msg) } -fn slider_row<'a>( - letter: &'static str, - slider: GradientSlider<'a, Message>, - number: Element<'a, Message>, -) -> Element<'a, Message> { - row![ - container(text(letter).size(12).font(iced::Font::MONOSPACE)) - .width(12) - .align_x(iced::Center), - slider, - number, - ] - .spacing(8) - .align_y(iced::Center) - .into() -} - -/// A compact spin box: iced_aw's NumberInput keeps its stacked arrows as long -/// as its padding stays at the default. -fn number_box( - value: u16, - max: u16, - unit: Option, - on_change: impl Fn(u16) -> Message + Clone + 'static, -) -> Element<'static, Message> { - let input = iced_aw::widget::NumberInput::new(&value, 0..=max, on_change) - .set_size(12.0) - .font(iced::Font::MONOSPACE) - .width(Length::Fixed(68.0)) - // Extra right padding keeps the digits clear of the arrows; padding - // below 5 would make NumberInput swap its arrows for + / − buttons. - .padding(iced::Padding { - top: 5.0, - right: 18.0, - bottom: 5.0, - left: 6.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), - }, - ); - // The unit is drawn inside the box, after the digits, but is not part - // of the value: the user only ever types the number. - let input = 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, - }), - None => input, - }; - input.into() -} - -fn hue_stops() -> Vec<(f32, Color)> { - (0..=6) - .map(|step| { - let offset = step as f32 / 6.0; - (offset, hsb_color(offset * 360.0, 1.0, 1.0)) - }) - .collect() -} - -fn hsb_color(h: f32, s: f32, b: f32) -> Color { - let (r, g, b) = math::hsb_to_rgb(h, s, b); - rgb_color(r, g, b) -} - -fn rgb_color(r: u8, g: u8, b: u8) -> Color { - Color::from_rgb8(r, g, b) -} - fn ink_is_dark(color: Color) -> bool { - math::ink_is_dark( - (color.r * 255.0) as u8, - (color.g * 255.0) as u8, - (color.b * 255.0) as u8, - ) + let byte = |c: f32| (c * 255.0).round() as u8; + math::ink_is_dark(byte(color.r), byte(color.g), byte(color.b)) } /// Upper-case label for a logical colour shown on a swatch, empty otherwise. @@ -859,15 +534,22 @@ fn logical_badge(color: AcadColor) -> String { } } -fn muted_text(theme: &Theme) -> text::Style { +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(_theme: &Theme, fill: Color) -> container::Style { +fn swatch_style(fill: Color) -> container::Style { container::Style { - background: Some(Background::Color(fill)), + background: Some(fill.into()), border: Border { color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), width: 1.0, @@ -877,14 +559,26 @@ fn swatch_style(_theme: &Theme, fill: Color) -> container::Style { } } +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(); - let hovered = matches!(status, button::Status::Hovered); button::Style { background: if active { - Some(Background::Color(palette.background.strong.color)) - } else if hovered { - Some(Background::Color(palette.background.weak.color)) + Some(palette.background.strong.color.into()) + } else if matches!(status, button::Status::Hovered) { + Some(palette.background.weak.color.into()) } else { None }, @@ -901,13 +595,12 @@ fn segment_style(theme: &Theme, active: bool, status: button::Status) -> button: } } -fn toggle_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { +pub(super) fn toggle_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { let palette = theme.palette(); - let hovered = matches!(status, button::Status::Hovered); button::Style { background: Some(Background::Color(if active { palette.primary.weak.color - } else if hovered { + } else if matches!(status, button::Status::Hovered) { palette.background.weak.color } else { palette.background.base.color @@ -932,12 +625,17 @@ fn toggle_style(theme: &Theme, active: bool, status: button::Status) -> button:: #[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_tab_that_fits_the_current_colour() { assert_eq!(picker(AcadColor::Index(30)).tab(), ColorPickerTab::Index); @@ -980,6 +678,16 @@ mod tests { b: 252 })) ); + 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 + })) + ); } #[test] @@ -991,42 +699,12 @@ mod tests { } #[test] - fn sliders_produce_true_colours() { + fn the_editor_produces_true_colours() { let mut p = picker(AcadColor::Index(1)); - p.update(ColorPickerMsg::Hue(120.0)); + 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 }); - p.update(ColorPickerMsg::Red(10)); - assert_eq!( - p.pending(), - AcadColor::Rgb { - r: 10, - g: 255, - b: 0 - } - ); - } - - #[test] - fn hue_survives_a_trip_through_gray() { - let mut p = picker(AcadColor::Rgb { r: 0, g: 0, b: 255 }); - p.update(ColorPickerMsg::Saturation(0.0)); - p.update(ColorPickerMsg::Saturation(1.0)); - assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 0, b: 255 }); - } - - #[test] - fn hex_field_sets_a_true_colour() { - let mut p = picker(AcadColor::Index(1)); - p.update(ColorPickerMsg::HexInput("#3bf".into())); - p.update(ColorPickerMsg::HexSubmit); - assert_eq!( - p.pending(), - AcadColor::Rgb { - r: 0x33, - g: 0xBB, - b: 0xFF - } - ); } #[test] @@ -1051,6 +729,39 @@ mod tests { 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 + } + ); + } + // ── Headless UI tests ─────────────────────────────────────────────── // // These run the real widgets through iced's simulator. Setting @@ -1093,75 +804,108 @@ mod tests { g: 191, b: 252, }); - p.update(ColorPickerMsg::Mode(TrueColorMode::Rgb)); + edit(&mut p, EditorMsg::Mode(EditorMode::Rgb)); let mut ui = simulator(&p); - // The first slider row sits below the tabs, preview row and mode - // buttons; press near its left end. - let red_row = ui.find("R").expect("R label"); - let bounds = red_row.visible_bounds().expect("R visible"); + 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::Red(v) if *v < 57))); + .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::Background(crate::app::BgTarget::Model), + 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 views: [(&str, AcadColor, ColorPickerTab, TrueColorMode, bool); 6] = [ + let blue = AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }; + let views: [( + &str, + ColorPickTarget, + AcadColor, + ColorPickerTab, + EditorMode, + bool, + ); 7] = [ ( "index", + ColorPickTarget::Properties, AcadColor::Index(140), ColorPickerTab::Index, - TrueColorMode::Hsb, + EditorMode::Hsb, false, ), ( "varies", + ColorPickTarget::Properties, AcadColor::Index(30), ColorPickerTab::Index, - TrueColorMode::Hsb, + EditorMode::Hsb, true, ), ( "hsb", - AcadColor::Rgb { - r: 57, - g: 191, - b: 252, - }, + ColorPickTarget::Properties, + blue, ColorPickerTab::TrueColor, - TrueColorMode::Hsb, + EditorMode::Hsb, false, ), ( "rgb", - AcadColor::Rgb { - r: 57, - g: 191, - b: 252, - }, + ColorPickTarget::Properties, + blue, ColorPickerTab::TrueColor, - TrueColorMode::Rgb, + EditorMode::Rgb, false, ), ( - "gradient", - AcadColor::Rgb { - r: 57, - g: 191, - b: 252, - }, + "field", + ColorPickTarget::Properties, + blue, ColorPickerTab::TrueColor, - TrueColorMode::Gradient, + EditorMode::Field, false, ), ( "exact", + ColorPickTarget::Properties, AcadColor::Rgb { r: 255, g: 0, b: 0 }, ColorPickerTab::TrueColor, - TrueColorMode::Hsb, + EditorMode::Hsb, + false, + ), + ( + "interface", + ColorPickTarget::Background(crate::app::BgTarget::Model), + AcadColor::Rgb { + r: 33, + g: 40, + b: 48, + }, + ColorPickerTab::TrueColor, + EditorMode::Hsb, false, ), ]; @@ -1169,10 +913,10 @@ mod tests { crate::ui::style::fusion_theme::fusion_black(), crate::ui::style::fusion_theme::fusion_white(), ] { - for (name, color, tab, mode, varies) in views { - let mut p = ColorPicker::new(ColorPickTarget::Properties, color, varies); + for (name, target, color, tab, mode, varies) in views.clone() { + let mut p = ColorPicker::new(target, color, varies); p.update(ColorPickerMsg::Tab(tab)); - p.update(ColorPickerMsg::Mode(mode)); + edit(&mut p, EditorMsg::Mode(mode)); let mut ui = simulator(&p); let snapshot = ui.snapshot(&theme).expect("snapshot"); if let Some(folder) = &folder { diff --git a/src/ui/color_select/widgets/gradient_slider.rs b/src/ui/color_select/widgets/gradient_slider.rs deleted file mode 100644 index 36893e9e3..000000000 --- a/src/ui/color_select/widgets/gradient_slider.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! A slider whose track is a colour gradient, with a white bar handle. -//! -//! Used for the HSB and RGB channels of the Select Color panel and, upright, -//! for the hue strip beside the saturation/brightness field. The value is -//! normalised to 0–1; callers map it to their own range. - -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, Shadow, Size, Theme, Vector, -}; - -const TRACK_THICKNESS: f32 = 18.0; -const TRACK_RADIUS: f32 = 5.0; -const HANDLE_THICKNESS: f32 = 8.0; -const HANDLE_RADIUS: f32 = 3.0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Orientation { - Horizontal, - Vertical, -} - -pub struct GradientSlider<'a, Message> { - value: f32, - stops: Vec<(f32, Color)>, - on_change: Box Message + 'a>, - orientation: Orientation, - /// How far one mouse-wheel notch moves the value (0–1 units). - wheel_step: f32, - width: Length, - height: Length, -} - -#[derive(Default)] -struct State { - dragging: bool, -} - -impl<'a, Message> GradientSlider<'a, Message> { - /// `value` is 0–1. `stops` are (offset 0–1, colour) pairs, at most eight. - pub fn new( - value: f32, - stops: Vec<(f32, Color)>, - on_change: impl Fn(f32) -> Message + 'a, - ) -> Self { - Self { - value: value.clamp(0.0, 1.0), - stops, - on_change: Box::new(on_change), - orientation: Orientation::Horizontal, - wheel_step: 0.01, - width: Length::Fill, - height: Length::Fixed(26.0), - } - } - - pub fn vertical(mut self) -> Self { - self.orientation = Orientation::Vertical; - self.width = Length::Fixed(22.0); - self.height = Length::Fill; - self - } - - pub fn wheel_step(mut self, step: f32) -> Self { - self.wheel_step = step; - self - } - - pub fn width(mut self, width: impl Into) -> Self { - self.width = width.into(); - self - } - - pub fn height(mut self, height: impl Into) -> Self { - self.height = height.into(); - self - } - - /// The span the handle centre travels along, in the slider's main axis. - fn travel(&self, bounds: Rectangle) -> (f32, f32) { - let inset = HANDLE_THICKNESS / 2.0; - match self.orientation { - Orientation::Horizontal => (bounds.x + inset, bounds.x + bounds.width - inset), - Orientation::Vertical => (bounds.y + inset, bounds.y + bounds.height - inset), - } - } - - fn value_at(&self, bounds: Rectangle, cursor: Point) -> f32 { - let (start, end) = self.travel(bounds); - let along = match self.orientation { - Orientation::Horizontal => cursor.x, - Orientation::Vertical => cursor.y, - }; - if end <= start { - return 0.0; - } - ((along - start) / (end - start)).clamp(0.0, 1.0) - } - - fn track_bounds(&self, bounds: Rectangle) -> Rectangle { - match self.orientation { - Orientation::Horizontal => { - let thickness = TRACK_THICKNESS.min(bounds.height); - Rectangle { - y: bounds.y + (bounds.height - thickness) / 2.0, - height: thickness, - ..bounds - } - } - Orientation::Vertical => bounds, - } - } - - fn gradient(&self) -> Background { - // Gradient angles run clockwise from "bottom to top": a quarter turn - // is left to right, a half turn top to bottom. - let angle = match self.orientation { - Orientation::Horizontal => std::f32::consts::FRAC_PI_2, - Orientation::Vertical => std::f32::consts::PI, - }; - let linear = self - .stops - .iter() - .fold(Linear::new(Radians(angle)), |linear, &(offset, color)| { - linear.add_stop(offset, color) - }); - Background::Gradient(Gradient::Linear(linear)) - } -} - -impl Widget for GradientSlider<'_, 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(); - match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { - if let Some(position) = cursor.position_over(bounds) { - state.dragging = true; - shell.publish((self.on_change)(self.value_at(bounds, position))); - shell.capture_event(); - } - } - Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { - if let Some(position) = cursor.position() { - shell.publish((self.on_change)(self.value_at(bounds, position))); - shell.capture_event(); - } - } - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => { - state.dragging = false; - shell.capture_event(); - } - Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => { - let lines = match delta { - mouse::ScrollDelta::Lines { y, .. } => *y, - mouse::ScrollDelta::Pixels { y, .. } => y.signum(), - }; - if lines != 0.0 { - // Scrolling up raises the value on a horizontal slider and - // moves the handle up on an upright one. - let direction = match self.orientation { - Orientation::Horizontal => lines.signum(), - Orientation::Vertical => -lines.signum(), - }; - let next = (self.value + direction * self.wheel_step).clamp(0.0, 1.0); - shell.publish((self.on_change)(next)); - 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 { - mouse::Interaction::Grabbing - } else if cursor.is_over(layout.bounds()) { - mouse::Interaction::Pointer - } 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(); - renderer.fill_quad( - renderer::Quad { - bounds: self.track_bounds(bounds), - border: Border { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), - width: 1.0, - radius: TRACK_RADIUS.into(), - }, - ..renderer::Quad::default() - }, - self.gradient(), - ); - - let (start, end) = self.travel(bounds); - let centre = start + (end - start) * self.value; - let handle = match self.orientation { - Orientation::Horizontal => Rectangle { - x: centre - HANDLE_THICKNESS / 2.0, - y: bounds.y, - width: HANDLE_THICKNESS, - height: bounds.height, - }, - Orientation::Vertical => Rectangle { - x: bounds.x - 3.0, - y: centre - HANDLE_THICKNESS / 2.0, - width: bounds.width + 6.0, - height: HANDLE_THICKNESS, - }, - }; - renderer.fill_quad( - renderer::Quad { - bounds: handle, - border: Border { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), - width: 1.0, - radius: HANDLE_RADIUS.into(), - }, - shadow: Shadow { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.55), - offset: Vector::new(0.0, 1.0), - blur_radius: 4.0, - }, - ..renderer::Quad::default() - }, - Color::WHITE, - ); - } -} - -impl<'a, Message: 'a> From> for Element<'a, Message> { - fn from(slider: GradientSlider<'a, Message>) -> Self { - Element::new(slider) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn slider() -> GradientSlider<'static, f32> { - GradientSlider::new(0.5, vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], |v| v) - } - - #[test] - fn cursor_maps_to_value_inside_handle_travel() { - let bounds = Rectangle::new(Point::ORIGIN, Size::new(108.0, 26.0)); - let s = slider(); - assert_eq!(s.value_at(bounds, Point::new(4.0, 10.0)), 0.0); - assert_eq!(s.value_at(bounds, Point::new(104.0, 10.0)), 1.0); - assert!((s.value_at(bounds, Point::new(54.0, 10.0)) - 0.5).abs() < 1e-6); - assert_eq!(s.value_at(bounds, Point::new(-50.0, 10.0)), 0.0); - assert_eq!(s.value_at(bounds, Point::new(500.0, 10.0)), 1.0); - } - - #[test] - fn vertical_slider_reads_the_y_axis() { - let bounds = Rectangle::new(Point::ORIGIN, Size::new(22.0, 108.0)); - let s = slider().vertical(); - assert_eq!(s.value_at(bounds, Point::new(11.0, 4.0)), 0.0); - assert_eq!(s.value_at(bounds, Point::new(11.0, 104.0)), 1.0); - } -} diff --git a/src/ui/color_select/widgets/mod.rs b/src/ui/color_select/widgets/mod.rs index b11df1c54..f25fc5ffa 100644 --- a/src/ui/color_select/widgets/mod.rs +++ b/src/ui/color_select/widgets/mod.rs @@ -1,7 +1,5 @@ //! Widgets the Select Color panel needs that iced does not provide. mod color_area; -mod gradient_slider; pub use color_area::ColorArea; -pub use gradient_slider::GradientSlider; diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 694b98a07..f995c252b 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"); diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index a6ff74874..99d116c9d 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -157,22 +157,18 @@ impl fmt::Display for Labelled { } } -/// A background swatch that opens the colour wheel when clicked. +/// A background swatch that opens the colour picker when clicked. /// -/// The wheel sits alongside the hex field rather than replacing it: typing +/// The picker sits alongside 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]); +/// picker is for choosing one by eye. +fn bg_swatch_picker<'a>(target: crate::app::BgTarget, rgb: [u8; 3]) -> Element<'a, Message> { + let [r, g, b] = rgb; 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, @@ -180,18 +176,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::Background(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)] @@ -227,7 +219,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, @@ -742,7 +733,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), + bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb), text_input("#RRGGBB", model_bg_input) .on_input(Message::ModelSpaceBgChanged) .width(150), @@ -755,7 +746,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), + bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb), text_input("#RRGGBB", paper_bg_input) .on_input(Message::PaperSpaceBgChanged) .width(150), @@ -767,7 +758,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), + bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb), text_input("#RRGGBB", desk_bg_input) .on_input(Message::DeskSpaceBgChanged) .width(150), From 78916d3ae7de520423e6f549b82dde5edd06a31c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:24:02 +0000 Subject: [PATCH 05/32] Select Color: full-width Current/New band, Tab focus, tighter fields - Current and New are a 50/50 band across the panel with their names and values inside; the Color text box is gone. - The gradient mode button is one gradient across the whole button, gray until that mode is active. Mode buttons share one width. - Tab and Shift+Tab move between the panel's own fields. - Number boxes are 54 px wide, the hex box 74 px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/update/mod.rs | 5 ++ src/ui/color_select/editor.rs | 40 +++++---- src/ui/color_select/math.rs | 117 ++++--------------------- src/ui/color_select/picker.rs | 157 ++++++++++------------------------ 4 files changed, 90 insertions(+), 229 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 14af0ca72..eddd93911 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -397,6 +397,11 @@ impl OpenCADStudio { 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, .. } => { diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index 770fbfcc6..d3c3b1df2 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -145,33 +145,37 @@ impl ColorEditor { let Hsb { h, s, b } = self.hsb; let (r, g, bl) = self.rgb; - let mode_button = |content: Element<'a, Message>, mode: EditorMode| { + let mode_button = |content: Element<'a, Message>, mode: EditorMode, padding: u16| { let active = self.mode == mode; - button(container(content).center_y(Length::Fill)) + button(container(content).center(Length::Fill)) .on_press(msg(EditorMsg::Mode(mode))) + .width(52) .height(28) - .padding([0, 10]) + .padding(padding) .style(move |theme: &Theme, status| { super::picker::toggle_style(theme, active, status) }) }; + // The field button is all gradient; it only takes colour when active. + let field_active = self.mode == EditorMode::Field; let field_icon = container(Space::new()) - .width(26) - .height(16) - .style(|_theme: &Theme| { + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme: &Theme| { + let middle = if field_active { + Color::from_rgb8(0x3A, 0xA0, 0xFF) + } else { + Color::from_rgb8(0x80, 0x80, 0x80) + }; gradient_box( 3.0 * std::f32::consts::FRAC_PI_4, - &[ - (0.0, Color::WHITE), - (0.5, Color::from_rgb8(0x3A, 0xA0, 0xFF)), - (1.0, Color::BLACK), - ], + &[(0.0, Color::WHITE), (0.5, middle), (1.0, Color::BLACK)], ) }); let mut modes = row![ - mode_button(text("HSB").size(12).into(), EditorMode::Hsb), - mode_button(text("RGB").size(12).into(), EditorMode::Rgb), - mode_button(field_icon.into(), EditorMode::Field), + mode_button(text("HSB").size(12).into(), EditorMode::Hsb, 0), + mode_button(text("RGB").size(12).into(), EditorMode::Rgb, 0), + mode_button(field_icon.into(), EditorMode::Field, 3), Space::new().width(Length::Fill), ] .spacing(6); @@ -291,8 +295,8 @@ impl ColorEditor { .on_submit(msg(EditorMsg::HexSubmit)) .size(12) .font(iced::Font::MONOSPACE) - .padding([5, 8]) - .width(Length::Fixed(92.0)), + .padding([5, 6]) + .width(Length::Fixed(74.0)), Space::new().width(Length::Fill), ] .spacing(8) @@ -359,14 +363,14 @@ fn number_box( }) .set_size(12.0) .font(iced::Font::MONOSPACE) - .width(Length::Fixed(68.0)) + .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: 6.0, + left: 2.0, }) .align_x(iced::alignment::Horizontal::Right) .style( diff --git a/src/ui/color_select/math.rs b/src/ui/color_select/math.rs index 4a01eff3a..4180d36d3 100644 --- a/src/ui/color_select/math.rs +++ b/src/ui/color_select/math.rs @@ -98,56 +98,11 @@ pub fn parse_hex(text: &str) -> Option<(u8, u8, u8)> { Some(((value >> 16) as u8, (value >> 8) as u8, value as u8)) } -/// Parse what the user typed in the Color field. -/// -/// Accepts an index (`140`, `aci 140`), a standard colour name (`red`), an -/// `r,g,b` triple, or hex (`#39BFFC`). Logical colours are not accepted: the -/// panel has no ByLayer / ByBlock entry. -pub fn parse_color_input(text: &str) -> Option { - let text = text.trim().to_ascii_lowercase(); - if text.is_empty() { - return None; - } - let named = match text.as_str() { - "red" => Some(1), - "yellow" => Some(2), - "green" => Some(3), - "cyan" => Some(4), - "blue" => Some(5), - "magenta" => Some(6), - "white" | "black" => Some(7), - _ => None, - }; - if let Some(index) = named { - return Some(AcadColor::Index(index)); - } - let number = text.strip_prefix("aci").map(str::trim).unwrap_or(&text); - if !number.is_empty() && number.chars().all(|c| c.is_ascii_digit()) { - return match number.parse::() { - Ok(n @ 1..=255) => Some(AcadColor::Index(n as u8)), - _ => None, - }; - } - if text.contains(',') { - let parts: Vec<&str> = text.split(',').map(str::trim).collect(); - if let [r, g, b] = parts.as_slice() { - let (r, g, b) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?); - return Some(AcadColor::Rgb { r, g, b }); - } - return None; - } - if text.starts_with('#') || text.len() == 6 { - let (r, g, b) = parse_hex(&text)?; - return Some(AcadColor::Rgb { r, g, b }); - } - None -} - -/// The text the Color field shows for a colour. -pub fn color_field_text(color: AcadColor) -> String { +/// The short name shown on the Current / New swatch. +pub fn short_name(color: AcadColor) -> String { match color { - AcadColor::Index(i) => i.to_string(), - AcadColor::Rgb { r, g, b } => format!("{r}, {g}, {b}"), + 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(), @@ -162,7 +117,7 @@ pub fn describe(color: AcadColor) -> String { format!("ACI {i} · RGB {r}, {g}, {b}") } AcadColor::Rgb { r, g, b } => format!("RGB {r}, {g}, {b} · {}", hex_string(r, g, b)), - other => color_field_text(other), + other => short_name(other), } } @@ -212,6 +167,20 @@ mod tests { 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"); @@ -221,52 +190,4 @@ mod tests { assert_eq!(parse_hex("#39BFF"), None); assert_eq!(parse_hex("zzzzzz"), None); } - - #[test] - fn colour_field_accepts_index_name_rgb_and_hex() { - assert_eq!(parse_color_input("140"), Some(AcadColor::Index(140))); - assert_eq!(parse_color_input(" ACI 7 "), Some(AcadColor::Index(7))); - assert_eq!(parse_color_input("Red"), Some(AcadColor::Index(1))); - assert_eq!( - parse_color_input("57, 191,252"), - Some(AcadColor::Rgb { - r: 57, - g: 191, - b: 252 - }) - ); - assert_eq!( - parse_color_input("#39BFFC"), - Some(AcadColor::Rgb { - r: 57, - g: 191, - b: 252 - }) - ); - } - - #[test] - fn colour_field_rejects_logical_and_out_of_range_values() { - assert_eq!(parse_color_input("bylayer"), None); - assert_eq!(parse_color_input("byblock"), None); - assert_eq!(parse_color_input("0"), None); - assert_eq!(parse_color_input("256"), None); - assert_eq!(parse_color_input("300,0,0"), None); - assert_eq!(parse_color_input("1,2"), None); - assert_eq!(parse_color_input(""), None); - } - - #[test] - fn field_text_matches_parser() { - for color in [ - AcadColor::Index(140), - AcadColor::Rgb { - r: 57, - g: 191, - b: 252, - }, - ] { - assert_eq!(parse_color_input(&color_field_text(color)), Some(color)); - } - } } diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index bc13a55bf..f620c8845 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -12,14 +12,16 @@ use crate::app::{ArrowKey, ColorPickTarget, Message}; use crate::t; use crate::ui::properties::acad_color_display; use codec::types::Color as AcadColor; -use iced::widget::{button, column, container, row, text, text_input, Space}; +use iced::widget::{button, column, container, row, text, Space}; use iced::{Background, Border, Color, Element, Length, Theme}; const PANEL_WIDTH: f32 = 400.0; +/// Scope for Tab / Shift+Tab, so focus cycles through the panel's own +/// fields instead of the dialogs and command line underneath. +const PANEL_ID: &str = "select-color-panel"; const BODY_HEIGHT: f32 = 352.0; /// The interface picker has no tabs or info line, only the editor. const EDITOR_ONLY_HEIGHT: f32 = 258.0; -const SWATCH_WIDTH: f32 = 104.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ColorPickerTab { @@ -36,8 +38,6 @@ pub enum ColorPickerMsg { /// Set the new colour back to the current one. Revert, Editor(EditorMsg), - FieldInput(String), - FieldSubmit, /// Replace the true colour with its nearest index colour. UseNearest, Arrow(ArrowKey), @@ -64,7 +64,6 @@ pub struct ColorPicker { pending: AcadColor, tab: ColorPickerTab, editor: ColorEditor, - field_draft: Option, eyedropper: Option, } @@ -82,7 +81,6 @@ impl ColorPicker { pending: current, tab, editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))), - field_draft: None, eyedropper: None, } } @@ -102,7 +100,6 @@ impl ColorPicker { fn set_pending(&mut self, color: AcadColor) { self.pending = color; - self.field_draft = None; if let Some(rgb) = math::color_rgb(color) { self.editor.set_rgb(rgb); } @@ -142,17 +139,6 @@ impl ColorPicker { if self.editor.update(editor) { let (r, g, b) = self.editor.rgb(); self.pending = AcadColor::Rgb { r, g, b }; - self.field_draft = None; - } - } - ColorPickerMsg::FieldInput(value) => self.field_draft = Some(value), - ColorPickerMsg::FieldSubmit => { - if let Some(color) = self - .field_draft - .take() - .and_then(|d| math::parse_color_input(&d)) - { - self.set_pending(color); } } ColorPickerMsg::UseNearest => { @@ -172,7 +158,6 @@ impl ColorPicker { ColorPickerMsg::Eyedropper(_) => {} ColorPickerMsg::Ok => { // Apply a value still being typed, as if Enter had been pressed. - self.update(ColorPickerMsg::FieldSubmit); self.update(ColorPickerMsg::Editor(EditorMsg::HexSubmit)); return Some(Outcome::Commit(self.pending)); } @@ -246,10 +231,8 @@ impl ColorPicker { if !editor_only { panel = panel.push(self.tabs()); } - panel - .push(self.preview_row()) - .push(body) - .push(actions) + container(panel.push(self.preview_row()).push(body).push(actions)) + .id(PANEL_ID) .into() } @@ -274,33 +257,32 @@ impl ColorPicker { .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 caption = |label: String| text(label).size(11).style(muted_text); - let swatch_text = |label: String, background: Color| { - let ink = if ink_is_dark(background) { + let half = |label: String, value: String, fill: Color| { + let ink = if ink_is_dark(fill) { Color::from_rgb8(0x11, 0x12, 0x14) } else { Color::WHITE }; - container( - text(label) - .size(9) - .font(iced::Font { - weight: iced::font::Weight::Bold, - ..iced::Font::DEFAULT - }) - .color(ink), - ) - .center(Length::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, _) = acad_color_display(self.current); let (new_bg, _) = acad_color_display(self.pending); - let current_label = if self.varies { + let current_value = if self.varies { "*VARIES*".to_string() } else { - logical_badge(self.current) + math::short_name(self.current) }; - let current = button(swatch_text(current_label, current_bg)) + let current = button(half(t!("Current").into_owned(), current_value, current_bg)) .on_press(picker_msg(ColorPickerMsg::Revert)) .padding(0) .width(Length::Fill) @@ -309,60 +291,26 @@ impl ColorPicker { background: Some(current_bg.into()), ..Default::default() }); - let new = container(swatch_text(logical_badge(self.pending), new_bg)) + let new = container(half( + t!("New").into_owned(), + math::short_name(self.pending), + new_bg, + )) + .style(move |_theme: &Theme| filled(new_bg)); + container(row![current, new]) .width(Length::Fill) - .height(Length::Fill) - .style(move |_theme: &Theme| filled(new_bg)); - 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 swatch = container(row![current, divider, new]) - .width(SWATCH_WIDTH) - .height(28) + .height(60) .padding(1) .style(|theme: &Theme| container::Style { border: Border { color: theme.palette().background.strong.color, width: 1.0, - radius: 6.0.into(), + radius: 8.0.into(), }, ..Default::default() }) - .clip(true); - - let mut preview = row![column![ - row![ - caption(t!("Current").into_owned()), - Space::new().width(Length::Fill), - caption(t!("New").into_owned()), - ] - .width(SWATCH_WIDTH), - swatch, - ] - .spacing(4)] - .spacing(10) - .align_y(iced::Bottom); - if !self.editor_only() { - let field_text = self - .field_draft - .clone() - .unwrap_or_else(|| math::color_field_text(self.pending)); - preview = preview.push( - column![ - caption(t!("Color").into_owned()), - text_input("", &field_text) - .on_input(|value| picker_msg(ColorPickerMsg::FieldInput(value))) - .on_submit(picker_msg(ColorPickerMsg::FieldSubmit)) - .size(12) - .font(iced::Font::MONOSPACE) - .padding([6, 10]), - ] - .spacing(4) - .width(Length::Fill), - ); - } - preview.into() + .clip(true) + .into() } fn index_tab(&self) -> Element<'_, Message> { @@ -515,6 +463,17 @@ fn step_index(index: u8, direction: ArrowKey) -> Option { } } +/// 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) } @@ -524,16 +483,6 @@ fn ink_is_dark(color: Color) -> bool { math::ink_is_dark(byte(color.r), byte(color.g), byte(color.b)) } -/// Upper-case label for a logical colour shown on a swatch, empty otherwise. -fn logical_badge(color: AcadColor) -> String { - match color { - AcadColor::ByLayer => "BYLAYER".to_string(), - AcadColor::ByBlock => "BYBLOCK".to_string(), - AcadColor::None => "NONE".to_string(), - _ => String::new(), - } -} - fn filled(color: Color) -> container::Style { container::Style { background: Some(color.into()), @@ -667,17 +616,7 @@ mod tests { } #[test] - fn ok_applies_text_still_being_typed() { - let mut p = picker(AcadColor::Index(30)); - p.update(ColorPickerMsg::FieldInput("57,191,252".into())); - assert_eq!( - p.update(ColorPickerMsg::Ok), - Some(Outcome::Commit(AcadColor::Rgb { - r: 57, - g: 191, - b: 252 - })) - ); + 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!( @@ -690,14 +629,6 @@ mod tests { ); } - #[test] - fn invalid_field_text_keeps_the_colour() { - let mut p = picker(AcadColor::Index(30)); - p.update(ColorPickerMsg::FieldInput("bylayer".into())); - p.update(ColorPickerMsg::FieldSubmit); - assert_eq!(p.pending(), AcadColor::Index(30)); - } - #[test] fn the_editor_produces_true_colours() { let mut p = picker(AcadColor::Index(1)); From 0a28163ef5f9e55530a9263426e5d7f02a640ee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:26:05 +0000 Subject: [PATCH 06/32] Round the Current/New band halves and add a divider Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/picker.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index f620c8845..5d9668c42 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -289,15 +289,29 @@ impl ColorPicker { .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(), math::short_name(self.pending), new_bg, )) - .style(move |_theme: &Theme| filled(new_bg)); - container(row![current, new]) + .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) From c8c5f667f7452adde56658da1b229d76253ccf52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:33:23 +0000 Subject: [PATCH 07/32] Select Color: frameless gradient button, hex beside H/S/B, all Options swatches - The gradient mode button is just its gradient, without a frame. - In the gradient view H, S and B sit on the left and Hex on the right of one row; the nearest index colour moves to the row below. - Every colour swatch in Options opens the Select Color panel: the six theme colours, crosshair, snap marker and command line text join the three viewport backgrounds. BgTarget becomes UiColor, and one color_swatch helper replaces the four hand-built swatches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/mod.rs | 19 ++++--- src/app/update/mod.rs | 2 +- src/app/update/style.rs | 19 ++++--- src/ui/color_select/editor.rs | 83 ++++++++++++++++++----------- src/ui/color_select/picker.rs | 6 +-- src/ui/window/options.rs | 98 ++++++++--------------------------- 6 files changed, 102 insertions(+), 125 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 1c9cacc71..917215db4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1577,12 +1577,17 @@ pub struct SaveOutcome { refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } -/// Which viewport background the interface colour picker 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, } /// Where a colour chosen in the standalone palette window should be applied. @@ -1607,8 +1612,8 @@ pub enum ColorPickTarget { LayerState(usize), /// The MText editor's selection (or global) colour. MText, - /// An Options viewport background (interface colour, true colour only). - Background(BgTarget), + /// An Options interface colour (true colour only). + Interface(UiColor), } /// Table records the clipboard entities depend on, snapshotted from the source diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index eddd93911..2dbc0c81a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11061,7 +11061,7 @@ impl OpenCADStudio { // the recent list. if !matches!( self.color_picker.as_ref().map(|picker| &picker.target), - Some(crate::app::ColorPickTarget::Background(_)) + Some(crate::app::ColorPickTarget::Interface(_)) ) { self.note_recent_color(color); } diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 0db169c56..62953b487 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1136,7 +1136,7 @@ impl OpenCADStudio { } pub(super) fn on_color_window_pick(&mut self, color: codec::types::Color) -> Task { - if let Some(crate::app::ColorPickTarget::Background(target)) = self + if let Some(crate::app::ColorPickTarget::Interface(target)) = self .color_picker .as_ref() .map(|picker| picker.target.clone()) @@ -1146,13 +1146,18 @@ impl OpenCADStudio { 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, - // persistence or the MatchTheme fallback. + // 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 { - crate::app::BgTarget::Model => Message::ModelSpaceBgChanged(hex), - crate::app::BgTarget::Paper => Message::PaperSpaceBgChanged(hex), - crate::app::BgTarget::Desk => Message::DeskSpaceBgChanged(hex), + 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!( @@ -1208,7 +1213,7 @@ impl OpenCADStudio { } // Handled before this match. Some( - crate::app::ColorPickTarget::PlotStyle | crate::app::ColorPickTarget::Background(_), + crate::app::ColorPickTarget::PlotStyle | crate::app::ColorPickTarget::Interface(_), ) => None, None => None, }; diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index d3c3b1df2..12f286632 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -145,37 +145,46 @@ impl ColorEditor { let Hsb { h, s, b } = self.hsb; let (r, g, bl) = self.rgb; - let mode_button = |content: Element<'a, Message>, mode: EditorMode, padding: u16| { + let mode_button = |content: Element<'a, Message>, mode: EditorMode| { let active = self.mode == mode; button(container(content).center(Length::Fill)) .on_press(msg(EditorMsg::Mode(mode))) .width(52) .height(28) - .padding(padding) + .padding(0) .style(move |theme: &Theme, status| { super::picker::toggle_style(theme, active, status) }) }; - // The field button is all gradient; it only takes colour when active. + // The field button is only its gradient, without a frame; it takes + // colour when its mode is active and stays gray otherwise. let field_active = self.mode == EditorMode::Field; - let field_icon = container(Space::new()) - .width(Length::Fill) - .height(Length::Fill) - .style(move |_theme: &Theme| { + let field_button = button(Space::new().width(52).height(28)) + .on_press(msg(EditorMsg::Mode(EditorMode::Field))) + .padding(0) + .style(move |_theme: &Theme, _status| { let middle = if field_active { Color::from_rgb8(0x3A, 0xA0, 0xFF) } else { Color::from_rgb8(0x80, 0x80, 0x80) }; - gradient_box( + let icon = gradient_box( 3.0 * std::f32::consts::FRAC_PI_4, &[(0.0, Color::WHITE), (0.5, middle), (1.0, Color::BLACK)], - ) + ); + button::Style { + background: icon.background, + border: Border { + radius: 5.0.into(), + ..Border::default() + }, + ..Default::default() + } }); let mut modes = row![ - mode_button(text("HSB").size(12).into(), EditorMode::Hsb, 0), - mode_button(text("RGB").size(12).into(), EditorMode::Rgb, 0), - mode_button(field_icon.into(), EditorMode::Field, 3), + mode_button(text("HSB").size(12).into(), EditorMode::Hsb), + mode_button(text("RGB").size(12).into(), EditorMode::Rgb), + field_button, Space::new().width(Length::Fill), ] .spacing(6); @@ -183,6 +192,26 @@ impl ColorEditor { modes = modes.push(end); } + let hex = self + .hex_draft + .clone() + .unwrap_or_else(|| math::hex_string(r, g, bl)); + let hex_field = move || { + 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)), + ] + .spacing(8) + .align_y(iced::Center) + }; + let field_mode = self.mode == EditorMode::Field; + let editor: Element<'a, Message> = match self.mode { EditorMode::Hsb => column![ channel("H", h, 360.0, Some('°'), hue_stops(), EditorMsg::Hue), @@ -270,13 +299,15 @@ impl ColorEditor { }; column![ row![area, strip].spacing(12), + // H, S and B on the left, the hex value on the right. row![ value("H", h, 360.0, '°', EditorMsg::Hue), - Space::new().width(Length::Fill), value("S", s * 100.0, 100.0, '%', EditorMsg::Saturation), - Space::new().width(Length::Fill), value("B", b * 100.0, 100.0, '%', EditorMsg::Brightness), + Space::new().width(Length::Fill), + hex_field(), ] + .spacing(10) .align_y(iced::Center), ] .spacing(10) @@ -284,23 +315,13 @@ impl ColorEditor { } }; - let hex = self - .hex_draft - .clone() - .unwrap_or_else(|| math::hex_string(r, g, bl)); - 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); + // Below the editor: the hex value (unless the field view already + // shows it) and, on the right, the nearest index colour. + let mut hex_row = row![].spacing(8).align_y(iced::Center); + if !field_mode { + hex_row = hex_row.push(hex_field()); + } + hex_row = hex_row.push(Space::new().width(Length::Fill)); if let Some(end) = hex_end { hex_row = hex_row.push(end); } diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 5d9668c42..2043267cb 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -95,7 +95,7 @@ impl ColorPicker { /// Interface colours (Options backgrounds) only get the colour editor. fn editor_only(&self) -> bool { - matches!(self.target, ColorPickTarget::Background(_)) + matches!(self.target, ColorPickTarget::Interface(_)) } fn set_pending(&mut self, color: AcadColor) { @@ -763,7 +763,7 @@ mod tests { #[test] fn interface_colours_show_only_the_editor() { let p = ColorPicker::new( - ColorPickTarget::Background(crate::app::BgTarget::Model), + ColorPickTarget::Interface(crate::app::UiColor::ModelBackground), AcadColor::Rgb { r: 30, g: 30, @@ -843,7 +843,7 @@ mod tests { ), ( "interface", - ColorPickTarget::Background(crate::app::BgTarget::Model), + ColorPickTarget::Interface(crate::app::UiColor::ModelBackground), AcadColor::Rgb { r: 33, g: 40, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 99d116c9d..5b1ea49f9 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -157,13 +157,12 @@ impl fmt::Display for Labelled { } } -/// A background swatch that opens the colour picker when clicked. +/// A colour swatch that opens the Select Color panel when clicked. /// -/// The picker 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 -/// picker is for choosing one by eye. -fn bg_swatch_picker<'a>(target: crate::app::BgTarget, rgb: [u8; 3]) -> Element<'a, Message> { - let [r, g, b] = rgb; +/// 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) @@ -178,7 +177,7 @@ fn bg_swatch_picker<'a>(target: crate::app::BgTarget, rgb: [u8; 3]) -> Element<' }); button(swatch) .on_press(Message::OpenColorWindow( - crate::app::ColorPickTarget::Background(target), + crate::app::ColorPickTarget::Interface(target), codec::types::Color::Rgb { r, g, b }, )) .padding(0) @@ -298,18 +297,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), @@ -519,61 +508,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 @@ -733,7 +679,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), + color_swatch(crate::app::UiColor::ModelBackground, model_bg_rgb), text_input("#RRGGBB", model_bg_input) .on_input(Message::ModelSpaceBgChanged) .width(150), @@ -746,7 +692,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), + color_swatch(crate::app::UiColor::PaperBackground, paper_bg_rgb), text_input("#RRGGBB", paper_bg_input) .on_input(Message::PaperSpaceBgChanged) .width(150), @@ -758,7 +704,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), + color_swatch(crate::app::UiColor::DeskBackground, desk_bg_rgb), text_input("#RRGGBB", desk_bg_input) .on_input(Message::DeskSpaceBgChanged) .width(150), From 936c3b91aa028288e22b1f6ff5d24dcb3d0e71b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:53:51 +0000 Subject: [PATCH 08/32] Select Color: aligned views, smaller mode buttons, compact interface picker - The gradient view no longer shows the nearest index colour. - Mode buttons are 44 x 24 and the pipette button matches their height. - Slider rows are 14 px apart and the gradient field is 108 px tall, so the hex value sits on the same row in the HSB, RGB and gradient views. - The interface colour picker (Options) shrinks to its content, ending just below the hex row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/editor.rs | 41 ++++++++++++++++++++--------------- src/ui/color_select/picker.rs | 17 ++++++++------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index 12f286632..56df1c20a 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -15,7 +15,12 @@ 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 FIELD_HEIGHT: f32 = 118.0; +/// Gap between slider rows. Together with `FIELD_HEIGHT` it puts the hex row +/// at the same height in every mode: 3 × 26 + 2 × 14 + 12 = 108 + 10. +const ROW_GAP: f32 = 14.0; +const FIELD_HEIGHT: f32 = 108.0; +const MODE_WIDTH: f32 = 44.0; +const MODE_HEIGHT: f32 = 24.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum EditorMode { @@ -149,8 +154,8 @@ impl ColorEditor { let active = self.mode == mode; button(container(content).center(Length::Fill)) .on_press(msg(EditorMsg::Mode(mode))) - .width(52) - .height(28) + .width(MODE_WIDTH) + .height(MODE_HEIGHT) .padding(0) .style(move |theme: &Theme, status| { super::picker::toggle_style(theme, active, status) @@ -159,7 +164,7 @@ impl ColorEditor { // The field button is only its gradient, without a frame; it takes // colour when its mode is active and stays gray otherwise. let field_active = self.mode == EditorMode::Field; - let field_button = button(Space::new().width(52).height(28)) + let field_button = button(Space::new().width(MODE_WIDTH).height(MODE_HEIGHT)) .on_press(msg(EditorMsg::Mode(EditorMode::Field))) .padding(0) .style(move |_theme: &Theme, _status| { @@ -182,8 +187,8 @@ impl ColorEditor { } }); let mut modes = row![ - mode_button(text("HSB").size(12).into(), EditorMode::Hsb), - mode_button(text("RGB").size(12).into(), EditorMode::Rgb), + mode_button(text("HSB").size(11).into(), EditorMode::Hsb), + mode_button(text("RGB").size(11).into(), EditorMode::Rgb), field_button, Space::new().width(Length::Fill), ] @@ -232,7 +237,7 @@ impl ColorEditor { EditorMsg::Brightness, ), ] - .spacing(10) + .spacing(ROW_GAP) .into(), EditorMode::Rgb => { let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| { @@ -310,23 +315,25 @@ impl ColorEditor { .spacing(10) .align_y(iced::Center), ] + // 108 + 10 matches the slider views' 3 × 26 + 2 × 14 + 12. .spacing(10) .into() } }; - // Below the editor: the hex value (unless the field view already - // shows it) and, on the right, the nearest index colour. - let mut hex_row = row![].spacing(8).align_y(iced::Center); + // The field view shows the hex value beside H, S and B and leaves out + // the nearest index colour; the slider views end on a hex row. + let mut view = column![modes, editor].spacing(12); if !field_mode { - hex_row = hex_row.push(hex_field()); + let mut hex_row = row![hex_field(), Space::new().width(Length::Fill)] + .spacing(8) + .align_y(iced::Center); + if let Some(end) = hex_end { + hex_row = hex_row.push(end); + } + view = view.push(hex_row); } - hex_row = hex_row.push(Space::new().width(Length::Fill)); - if let Some(end) = hex_end { - hex_row = hex_row.push(end); - } - - column![modes, editor, hex_row].spacing(12).into() + view.into() } } diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 2043267cb..fc502df26 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -20,8 +20,6 @@ const PANEL_WIDTH: f32 = 400.0; /// fields instead of the dialogs and command line underneath. const PANEL_ID: &str = "select-color-panel"; const BODY_HEIGHT: f32 = 352.0; -/// The interface picker has no tabs or info line, only the editor. -const EDITOR_ONLY_HEIGHT: f32 = 258.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ColorPickerTab { @@ -179,8 +177,11 @@ impl ColorPicker { self.index_tab() }; - let mut body = column![body, Space::new().height(Length::Fill)].spacing(8); + // The interface picker shrinks to the editor; the CAD panel keeps one + // height across its tabs, with the info line at the bottom. + let mut body = column![body].spacing(8); if !editor_only { + body = body.push(Space::new().height(Length::Fill)); let fill = math::color_rgb(self.pending).map_or(Color::from_rgb8(128, 128, 128), rgb_color); body = body @@ -208,9 +209,9 @@ impl ColorPicker { .padding(12) .width(Length::Fill) .height(if editor_only { - EDITOR_ONLY_HEIGHT + Length::Shrink } else { - BODY_HEIGHT + Length::Fixed(BODY_HEIGHT) }) .style(panel_style); @@ -396,10 +397,10 @@ impl ColorPicker { None } else { Some( - button(crate::ui::icons::themed(crate::ui::icons::EYEDROPPER, 16.0)) + button(crate::ui::icons::themed(crate::ui::icons::EYEDROPPER, 14.0)) .on_press(picker_msg(ColorPickerMsg::Eyedropper(EyedropperMsg::Start))) - .height(28) - .padding([0, 8]) + .height(24) + .padding([0, 7]) .style(|theme: &Theme, status| toggle_style(theme, false, status)) .into(), ) From e6020be77f93df9fe000eb8674043e3ddc89a556 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:00:40 +0000 Subject: [PATCH 09/32] Select Color: 40 px mode buttons with larger labels, frameless pipette Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/editor.rs | 6 +++--- src/ui/color_select/picker.rs | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index 56df1c20a..f81a93e2a 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -19,7 +19,7 @@ const STRIP_WIDTH: f32 = 22.0; /// at the same height in every mode: 3 × 26 + 2 × 14 + 12 = 108 + 10. const ROW_GAP: f32 = 14.0; const FIELD_HEIGHT: f32 = 108.0; -const MODE_WIDTH: f32 = 44.0; +const MODE_WIDTH: f32 = 40.0; const MODE_HEIGHT: f32 = 24.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -187,8 +187,8 @@ impl ColorEditor { } }); let mut modes = row![ - mode_button(text("HSB").size(11).into(), EditorMode::Hsb), - mode_button(text("RGB").size(11).into(), EditorMode::Rgb), + mode_button(text("HSB").size(13).into(), EditorMode::Hsb), + mode_button(text("RGB").size(13).into(), EditorMode::Rgb), field_button, Space::new().width(Length::Fill), ] diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index fc502df26..ca28b2b85 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -397,11 +397,11 @@ impl ColorPicker { None } else { Some( - button(crate::ui::icons::themed(crate::ui::icons::EYEDROPPER, 14.0)) + // Just the icon, without a button frame. + button(crate::ui::icons::themed(crate::ui::icons::EYEDROPPER, 24.0)) .on_press(picker_msg(ColorPickerMsg::Eyedropper(EyedropperMsg::Start))) - .height(24) - .padding([0, 7]) - .style(|theme: &Theme, status| toggle_style(theme, false, status)) + .padding(0) + .style(|_theme: &Theme, _status| button::Style::default()) .into(), ) }; From ea9143cc876d7045cf76bf69f7a8cd9f0658a5af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:03:09 +0000 Subject: [PATCH 10/32] Select Color: gray pipette that takes the accent colour on hover Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/picker.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index ca28b2b85..090bc91fd 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -398,11 +398,28 @@ impl ColorPicker { } else { Some( // Just the icon, without a button frame. - button(crate::ui::icons::themed(crate::ui::icons::EYEDROPPER, 24.0)) - .on_press(picker_msg(ColorPickerMsg::Eyedropper(EyedropperMsg::Start))) - .padding(0) - .style(|_theme: &Theme, _status| button::Style::default()) - .into(), + 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()); From 7162f53c4d9638664ef015a7591e8142af793208 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:05:09 +0000 Subject: [PATCH 11/32] Select Color: use the shared row gap in the RGB view too Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index f81a93e2a..c83ce5fe4 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -270,7 +270,7 @@ impl ColorEditor { byte(EditorMsg::Blue) ), ] - .spacing(10) + .spacing(ROW_GAP) .into() } EditorMode::Field => { From cc40722f70f15ace0eb17882bf9cea90346b0a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:12:50 +0000 Subject: [PATCH 12/32] Select Color: small cleanups found in review - Keep the modal keyboard comment with the code it describes and match shortcut keys without an extra closure. - Drop unused ColorEditor::mode, EyedropperMsg::Cancel and a duplicate rgb helper; re-export nearest_aci instead of wrapping it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/update/mod.rs | 17 ++++++++--------- src/ui/color_select/editor.rs | 4 ---- src/ui/color_select/eyedropper.rs | 8 ++------ src/ui/color_select/math.rs | 6 +++--- src/ui/color_select/mod.rs | 8 ++------ src/ui/color_select/picker.rs | 9 ++++----- 6 files changed, 19 insertions(+), 33 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2dbc0c81a..6b25dd7e1 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -370,19 +370,11 @@ impl OpenCADStudio { let tab = &self.tabs[self.active_tab]; (started, tab.id, tab.scene.geometry_epoch) }); - // 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 - // commands once the dialog closes. While a modal is open, Escape - // closes it and every other keystroke-derived message is swallowed; - // the modal's own text fields keep working because they emit their own - // (non-blocked) messages. (#126) // 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; - let last_key = |key: &str| key.rsplit('+').next().map(str::to_owned); match &msg { Message::CommandEscape => { return self.update(Message::ColorPicker(ColorPickerMsg::Cancel)); @@ -390,7 +382,7 @@ impl OpenCADStudio { Message::CommandFinalize => { return self.update(Message::ColorPicker(ColorPickerMsg::Ok)); } - Message::ShortcutPressed(key) => match last_key(key).as_deref() { + Message::ShortcutPressed(key) => match key.rsplit('+').next() { Some("ESCAPE") => { return self.update(Message::ColorPicker(ColorPickerMsg::Cancel)); } @@ -413,6 +405,13 @@ impl OpenCADStudio { 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 + // commands once the dialog closes. While a modal is open, Escape + // closes it and every other keystroke-derived message is swallowed; + // the modal's own text fields keep working because they emit their own + // (non-blocked) messages. (#126) if self.active_modal.is_some() { if matches!(msg, Message::CommandEscape) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ESCAPE")) diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index c83ce5fe4..f003629c4 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -75,10 +75,6 @@ impl ColorEditor { self.rgb } - pub fn mode(&self) -> EditorMode { - self.mode - } - 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 diff --git a/src/ui/color_select/eyedropper.rs b/src/ui/color_select/eyedropper.rs index 87c839356..2c7050e38 100644 --- a/src/ui/color_select/eyedropper.rs +++ b/src/ui/color_select/eyedropper.rs @@ -5,6 +5,7 @@ //! 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}; @@ -23,7 +24,6 @@ pub enum EyedropperMsg { Move(Point), Shot(Screenshot), Pick, - Cancel, } #[derive(Default)] @@ -68,7 +68,7 @@ impl Eyedropper { 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); + 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 { @@ -133,10 +133,6 @@ impl Eyedropper { } } -fn rgb((r, g, b): (u8, u8, u8)) -> Color { - Color::from_rgb8(r, g, b) -} - /// 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); diff --git a/src/ui/color_select/math.rs b/src/ui/color_select/math.rs index 4180d36d3..8e4aa7e5d 100644 --- a/src/ui/color_select/math.rs +++ b/src/ui/color_select/math.rs @@ -1,8 +1,8 @@ -//! Colour maths and text parsing for the Select Color panel. +//! 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, nearest-index lookup and the parser behind the -//! panel's Color field. +//! conversion, hex formatting and parsing, nearest-index lookup and the +//! short names the panel shows. use codec::types::Color as AcadColor; diff --git a/src/ui/color_select/mod.rs b/src/ui/color_select/mod.rs index 591c1137f..ebf58586a 100644 --- a/src/ui/color_select/mod.rs +++ b/src/ui/color_select/mod.rs @@ -9,7 +9,8 @@ pub mod math; pub mod picker; pub mod widgets; -pub use picker::{ColorPicker, ColorPickerMsg, ColorPickerTab, Outcome}; +pub use math::nearest_aci; +pub use picker::{ColorPicker, ColorPickerMsg, Outcome}; use crate::app::Message; use crate::t; @@ -42,11 +43,6 @@ pub fn color_to_aci_string(c: AcadColor) -> String { } } -/// Return the closest CAD Color Index for an RGB colour. -pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 { - math::nearest_aci(r, g, b) -} - /// 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) { diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 090bc91fd..c1116f53a 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -2,7 +2,7 @@ //! //! 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 (an Options background) it shows only the +//! Opened for an interface colour (any colour in Options) it shows only the //! colour editor: no tabs, no index colours. use super::editor::{rgb_color, ColorEditor, EditorMsg}; @@ -91,7 +91,7 @@ impl ColorPicker { self.tab } - /// Interface colours (Options backgrounds) only get the colour editor. + /// Interface colours (Options) only get the colour editor. fn editor_only(&self) -> bool { matches!(self.target, ColorPickTarget::Interface(_)) } @@ -122,9 +122,8 @@ impl ColorPicker { self.set_pending(AcadColor::Rgb { r, g, b }); } } - ColorPickerMsg::Eyedropper(EyedropperMsg::Cancel) | ColorPickerMsg::Cancel => { - self.eyedropper = None; - } + // Esc leaves the eyedropper but keeps the panel open. + ColorPickerMsg::Cancel => self.eyedropper = None, _ => {} } return None; From fefd526c98c3a14ab2569249575399aa7db26b68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:21:38 +0000 Subject: [PATCH 13/32] Select Color: review cleanups - Split ColorEditor::view into mode_row, slider_rows, field_view, hex_row and hex_field; output is unchanged. - hex_string reuses config::rgb_to_hex, and parse_hex documents why it stays separate (it also accepts #RGB). - ink_is_dark uses Color::into_rgba8 instead of rounding by hand. - Remove four translation keys the old dialog used and nothing references any more, from the catalog and all 22 locales. - Eyedropper: without a main window there is no screenshot to wait for, so leave the eyedropper; keep the magnifier inside small windows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- locales/ar-SA/opencadstudio.ftl | 4 - locales/bg-BG/opencadstudio.ftl | 4 - locales/cs-CZ/opencadstudio.ftl | 4 - locales/de-DE/opencadstudio.ftl | 4 - locales/el-GR/opencadstudio.ftl | 4 - locales/en-US/opencadstudio.ftl | 4 - locales/es-ES/opencadstudio.ftl | 4 - locales/fi-FI/opencadstudio.ftl | 4 - locales/fr-FR/opencadstudio.ftl | 4 - locales/hi-IN/opencadstudio.ftl | 4 - locales/hu-HU/opencadstudio.ftl | 4 - locales/it-IT/opencadstudio.ftl | 4 - locales/ja-JP/opencadstudio.ftl | 4 - locales/ko-KR/opencadstudio.ftl | 4 - locales/lo-LA/opencadstudio.ftl | 4 - locales/nl-NL/opencadstudio.ftl | 4 - locales/pl-PL/opencadstudio.ftl | 4 - locales/pt-BR/opencadstudio.ftl | 4 - locales/ru-RU/opencadstudio.ftl | 4 - locales/tr-TR/opencadstudio.ftl | 4 - locales/zh-CN/opencadstudio.ftl | 4 - locales/zh-TW/opencadstudio.ftl | 4 - src/app/update/mod.rs | 11 +- src/locale_catalog.rs | 4 - src/ui/color_select/editor.rs | 247 ++++++++++++++++-------------- src/ui/color_select/eyedropper.rs | 7 +- src/ui/color_select/math.rs | 7 +- src/ui/color_select/picker.rs | 4 +- 28 files changed, 147 insertions(+), 221 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 358066656..35c51636b 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1334,8 +1334,6 @@ 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 } @@ -1343,9 +1341,7 @@ common = .nearest-index-color = أقرب فهرس .exact-index-match = تطابق تام مع الفهرس .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 3f7094552..6b313633a 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1321,8 +1321,6 @@ 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 } @@ -1330,9 +1328,7 @@ common = .nearest-index-color = Най-близък индекс .exact-index-match = Точно съвпадение с индекс .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 cad06d273..43e80d3bf 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1321,8 +1321,6 @@ 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 } @@ -1330,9 +1328,7 @@ common = .nearest-index-color = Nejbližší index .exact-index-match = Přesná shoda s indexem .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 3c4f4b9d5..350d07c6b 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1322,8 +1322,6 @@ 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 } @@ -1331,9 +1329,7 @@ common = .nearest-index-color = Nächster Index .exact-index-match = Exakter Index-Treffer .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 615a14c6d..4726011ca 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1323,8 +1323,6 @@ 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 } @@ -1332,9 +1330,7 @@ common = .nearest-index-color = Πλησιέστερος δείκτης .exact-index-match = Ακριβής αντιστοίχιση δείκτη .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 c4c86910b..961aaa81c 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1323,8 +1323,6 @@ 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 @@ -1332,9 +1330,7 @@ common = .nearest-index-color = Nearest index .exact-index-match = Exact index match .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 c21e510bc..3eeaa2d67 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1322,8 +1322,6 @@ 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 } @@ -1331,9 +1329,7 @@ common = .nearest-index-color = Índice más cercano .exact-index-match = Coincide con un índice .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 c8067d189..3bb153c47 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1321,8 +1321,6 @@ 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 } @@ -1330,9 +1328,7 @@ common = .nearest-index-color = Lähin indeksi .exact-index-match = Täsmälleen indeksiväri .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 014bb5e83..d8379605a 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1322,8 +1322,6 @@ 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 } @@ -1331,9 +1329,7 @@ common = .nearest-index-color = Index le plus proche .exact-index-match = Correspondance exacte d'index .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 8a35dd54e..d1dac675c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1310,8 +1310,6 @@ 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 } @@ -1319,9 +1317,7 @@ common = .nearest-index-color = निकटतम इंडेक्स .exact-index-match = इंडेक्स से सटीक मेल .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 4e0c17b55..2d06a13c5 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1321,8 +1321,6 @@ 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.standard } · { common.color } @@ -1330,9 +1328,7 @@ common = .nearest-index-color = Legközelebbi index .exact-index-match = Pontos indexegyezés .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 f1f5b6d5b..24d7aa5cc 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1321,8 +1321,6 @@ 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 } @@ -1330,9 +1328,7 @@ common = .nearest-index-color = Indice più vicino .exact-index-match = Corrispondenza esatta indice .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 c13aead95..92eaa0a80 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1312,8 +1312,6 @@ 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 } @@ -1321,9 +1319,7 @@ common = .nearest-index-color = 最も近いインデックス .exact-index-match = インデックスと完全一致 .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 9744e1e36..7078ff4dd 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1321,8 +1321,6 @@ 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 } @@ -1330,9 +1328,7 @@ common = .nearest-index-color = 가장 가까운 인덱스 .exact-index-match = 인덱스와 정확히 일치 .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 2082db648..0961364ed 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1323,8 +1323,6 @@ 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 = ສີມາດຕະຖານ @@ -1332,9 +1330,7 @@ common = .nearest-index-color = ດັດຊະນີທີ່ໃກ້ທີ່ສຸດ .exact-index-match = ກົງກັບດັດຊະນີພໍດີ .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 fdf8b12df..10c209ee6 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1320,8 +1320,6 @@ 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 } @@ -1329,9 +1327,7 @@ common = .nearest-index-color = Dichtstbijzijnde index .exact-index-match = Exacte indexkleur .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 c0ace6edb..df099a5de 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1321,8 +1321,6 @@ 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 } @@ -1330,9 +1328,7 @@ common = .nearest-index-color = Najbliższy indeks .exact-index-match = Dokładne dopasowanie indeksu .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 95c7f9245..047069429 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1322,8 +1322,6 @@ 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 } @@ -1331,9 +1329,7 @@ common = .nearest-index-color = Índice mais próximo .exact-index-match = Corresponde exatamente ao índice .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 29224ec62..d9ba65410 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1325,8 +1325,6 @@ 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 } @@ -1334,9 +1332,7 @@ common = .nearest-index-color = Ближайший индекс .exact-index-match = Точное совпадение с индексом .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 a5246382f..6574106d6 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1308,8 +1308,6 @@ 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 } @@ -1317,9 +1315,7 @@ common = .nearest-index-color = En yakın indeks .exact-index-match = Tam indeks eşleşmesi .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 0af74a805..cdc3d8540 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1309,8 +1309,6 @@ 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 } @@ -1318,9 +1316,7 @@ common = .nearest-index-color = 最接近的索引色 .exact-index-match = 与索引色完全匹配 .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 505cb7f59..5b2ea00b0 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1321,8 +1321,6 @@ 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 } @@ -1330,9 +1328,7 @@ common = .nearest-index-color = 最接近的索引色 .exact-index-match = 與索引色完全相符 .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/update/mod.rs b/src/app/update/mod.rs index 6b25dd7e1..8eece9b24 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11039,13 +11039,16 @@ impl OpenCADStudio { } Some(crate::ui::color_select::Outcome::Screenshot) => { use crate::ui::color_select::{eyedropper::EyedropperMsg, ColorPickerMsg}; - self.main_window.map_or_else(Task::none, |id| { - iced::window::screenshot(id).map(|shot| { + 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(), } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 2a01c1f43..6c943a16a 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4689,8 +4689,6 @@ 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")), @@ -4698,9 +4696,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Nearest index" => Some(("common", "nearest-index-color")), "Exact index match" => Some(("common", "exact-index-match")), "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/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index f003629c4..ea97384c7 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -138,14 +138,25 @@ impl ColorEditor { /// `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). + /// The field view shows the hex value beside H, S and B and leaves out + /// `hex_end`; the slider views end on a hex row. pub fn view<'a>( &'a self, mode_end: Option>, hex_end: Option>, ) -> Element<'a, Message> { - let Hsb { h, s, b } = self.hsb; - let (r, g, bl) = self.rgb; + let view = column![self.mode_row(mode_end)].spacing(12); + match self.mode { + EditorMode::Hsb | EditorMode::Rgb => view + .push(self.slider_rows()) + .push(self.hex_row(hex_end)) + .into(), + EditorMode::Field => view.push(self.field_view()).into(), + } + } + /// HSB, RGB and the gradient-field button, with `end` at the far right. + fn mode_row<'a>(&self, end: Option>) -> Element<'a, Message> { let mode_button = |content: Element<'a, Message>, mode: EditorMode| { let active = self.mode == mode; button(container(content).center(Length::Fill)) @@ -189,32 +200,50 @@ impl ColorEditor { Space::new().width(Length::Fill), ] .spacing(6); - if let Some(end) = mode_end { + if let Some(end) = end { modes = modes.push(end); } + modes.into() + } - let hex = self - .hex_draft - .clone() - .unwrap_or_else(|| math::hex_string(r, g, bl)); - let hex_field = move || { - 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)), + /// Three gradient sliders: hue, saturation and brightness, or red, green + /// and blue. + 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::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) + ), ] - .spacing(8) - .align_y(iced::Center) - }; - let field_mode = self.mode == EditorMode::Field; - - let editor: Element<'a, Message> = match self.mode { - EditorMode::Hsb => column![ + } else { + column![ channel("H", h, 360.0, Some('°'), hue_stops(), EditorMsg::Hue), channel( "S", @@ -233,103 +262,85 @@ impl ColorEditor { EditorMsg::Brightness, ), ] - .spacing(ROW_GAP) - .into(), - 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) - ), - ] - .spacing(ROW_GAP) - .into() - } - EditorMode::Field => { - 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(Length::Fixed(FIELD_HEIGHT)); - // A vertical slider grows upwards; the strip runs red at the top. - let strip = stack![ - container(Space::new()) - .width(STRIP_WIDTH) - .height(FIELD_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(FIELD_HEIGHT) - .style(handle_style), - ]; - let value = |letter: &'static str, - v: f32, - max: f32, - unit: char, - f: fn(f32) -> EditorMsg| { - row![ - text(letter).size(12).font(iced::Font::MONOSPACE), - number_box(v, max, Some(unit), f), - ] - .spacing(4) - .align_y(iced::Center) - }; - column![ - row![area, strip].spacing(12), - // H, S and B on the left, the hex value on the right. - row![ - value("H", h, 360.0, '°', EditorMsg::Hue), - value("S", s * 100.0, 100.0, '%', EditorMsg::Saturation), - value("B", b * 100.0, 100.0, '%', EditorMsg::Brightness), - Space::new().width(Length::Fill), - hex_field(), - ] - .spacing(10) - .align_y(iced::Center), - ] - // 108 + 10 matches the slider views' 3 × 26 + 2 × 14 + 12. - .spacing(10) - .into() - } }; + rows.spacing(ROW_GAP).into() + } - // The field view shows the hex value beside H, S and B and leaves out - // the nearest index colour; the slider views end on a hex row. - let mut view = column![modes, editor].spacing(12); - if !field_mode { - let mut hex_row = row![hex_field(), Space::new().width(Length::Fill)] - .spacing(8) - .align_y(iced::Center); - if let Some(end) = hex_end { - hex_row = hex_row.push(end); - } - view = view.push(hex_row); + /// The saturation/brightness field with its hue strip, and below it H, S + /// and B on the left and the hex value on the right. + fn field_view<'a>(&self) -> 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(Length::Fixed(FIELD_HEIGHT)); + // A vertical slider grows upwards; the strip runs red at the top. + let strip = stack![ + container(Space::new()) + .width(STRIP_WIDTH) + .height(FIELD_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(FIELD_HEIGHT) + .style(handle_style), + ]; + let value = + |letter: &'static str, v: f32, max: f32, unit: char, f: fn(f32) -> EditorMsg| { + row![ + text(letter).size(12).font(iced::Font::MONOSPACE), + number_box(v, max, Some(unit), f), + ] + .spacing(4) + .align_y(iced::Center) + }; + column![ + row![area, strip].spacing(12), + row![ + value("H", h, 360.0, '°', EditorMsg::Hue), + value("S", s * 100.0, 100.0, '%', EditorMsg::Saturation), + value("B", b * 100.0, 100.0, '%', EditorMsg::Brightness), + Space::new().width(Length::Fill), + self.hex_field(), + ] + .spacing(10) + .align_y(iced::Center), + ] + // 108 + 10 matches the slider views' 3 × 26 + 2 × 14 + 12. + .spacing(10) + .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 mut hex_row = row![self.hex_field(), Space::new().width(Length::Fill)] + .spacing(8) + .align_y(iced::Center); + if let Some(end) = end { + hex_row = hex_row.push(end); } - view.into() + hex_row.into() + } + + fn hex_field<'a>(&self) -> Element<'a, Message> { + let (r, g, b) = self.rgb; + let hex = self + .hex_draft + .clone() + .unwrap_or_else(|| math::hex_string(r, g, b)); + 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)), + ] + .spacing(8) + .align_y(iced::Center) + .into() } } diff --git a/src/ui/color_select/eyedropper.rs b/src/ui/color_select/eyedropper.rs index 2c7050e38..2d24822b9 100644 --- a/src/ui/color_select/eyedropper.rs +++ b/src/ui/color_select/eyedropper.rs @@ -119,16 +119,19 @@ impl Eyedropper { 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() } } diff --git a/src/ui/color_select/math.rs b/src/ui/color_select/math.rs index 8e4aa7e5d..74ab9968d 100644 --- a/src/ui/color_select/math.rs +++ b/src/ui/color_select/math.rs @@ -78,12 +78,13 @@ pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 { codec::types::aci_table::nearest_aci(r, g, b) } -/// `#RRGGBB`, upper case. +/// `#RRGGBB`, upper case — the same format the Options hex fields store. pub fn hex_string(r: u8, g: u8, b: u8) -> String { - format!("#{r:02X}{g:02X}{b:02X}") + crate::app::config::rgb_to_hex([r, g, b]) } -/// Parse `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB`. +/// 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()) { diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index c1116f53a..ccd63b85d 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -510,8 +510,8 @@ fn picker_msg(msg: ColorPickerMsg) -> Message { } fn ink_is_dark(color: Color) -> bool { - let byte = |c: f32| (c * 255.0).round() as u8; - math::ink_is_dark(byte(color.r), byte(color.g), byte(color.b)) + let [r, g, b, _] = color.into_rgba8(); + math::ink_is_dark(r, g, b) } fn filled(color: Color) -> container::Style { From c4597e7ba0fcfbb1507500fcd8846f38fa22a2c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:54:24 +0000 Subject: [PATCH 14/32] Select Color: Document and Color books tabs Document tab: every concrete colour the drawing uses (objects, block definitions and layers), found in one pass when the panel opens, as a grid of swatches sortable by recently used, most used, colour and colour number. The recently-used list is now kept per drawing, so the ribbon's Recent row shows this drawing's colours. Color books tab: load AutoCAD .acb colour books (remembered in the settings on desktop), browse them page by page with the page strip, search by name and see the RGB equivalent. A book colour is applied as RGB plus its BOOK$NAME identity on objects and layers instead of dropping the name. Strings translated into all 22 locales. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- locales/ar-SA/opencadstudio.ftl | 15 + locales/bg-BG/opencadstudio.ftl | 15 + locales/cs-CZ/opencadstudio.ftl | 15 + locales/de-DE/opencadstudio.ftl | 15 + locales/el-GR/opencadstudio.ftl | 15 + locales/en-US/opencadstudio.ftl | 15 + locales/es-ES/opencadstudio.ftl | 15 + locales/fi-FI/opencadstudio.ftl | 15 + locales/fr-FR/opencadstudio.ftl | 15 + locales/hi-IN/opencadstudio.ftl | 15 + locales/hu-HU/opencadstudio.ftl | 15 + locales/it-IT/opencadstudio.ftl | 15 + locales/ja-JP/opencadstudio.ftl | 15 + locales/ko-KR/opencadstudio.ftl | 15 + locales/lo-LA/opencadstudio.ftl | 15 + locales/nl-NL/opencadstudio.ftl | 15 + locales/pl-PL/opencadstudio.ftl | 15 + locales/pt-BR/opencadstudio.ftl | 15 + locales/ru-RU/opencadstudio.ftl | 15 + locales/tr-TR/opencadstudio.ftl | 15 + locales/zh-CN/opencadstudio.ftl | 15 + locales/zh-TW/opencadstudio.ftl | 15 + src/app/document.rs | 3 + src/app/mod.rs | 16 +- src/app/settings.rs | 4 + src/app/update/file.rs | 2 + src/app/update/mod.rs | 250 ++++++++--- src/app/update/style.rs | 16 +- src/app/view/mod.rs | 2 +- src/locale_catalog.rs | 15 + src/scene/view/dispatch.rs | 6 + src/ui/color_select/book.rs | 176 ++++++++ src/ui/color_select/document.rs | 233 ++++++++++ src/ui/color_select/mod.rs | 2 + src/ui/color_select/picker.rs | 758 +++++++++++++++++++++++++++++--- 35 files changed, 1684 insertions(+), 129 deletions(-) create mode 100644 src/ui/color_select/book.rs create mode 100644 src/ui/color_select/document.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 3e064a7f8..8f492e2b9 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1356,6 +1356,21 @@ common = .color-hex = سداسي عشري .nearest-index-color = أقرب فهرس .exact-index-match = تطابق تام مع الفهرس + .color-recently-used = المستخدمة مؤخرًا + .color-most-used = الأكثر استخدامًا + .color-number = رقم اللون + .color-in-drawing = الألوان في هذا الرسم + .color-sort = فرز + .color-no-drawing-colors = لا توجد ألوان في هذا الرسم بعد + .color-books = كتب الألوان + .color-no-books = لم يتم تحميل كتب ألوان + .color-load-book = تحميل كتاب ألوان (.acb) + .color-book-label = كتاب الألوان + .color-rgb-equivalent = مكافئ RGB + .color-no-match = لا توجد ألوان مطابقة + .color-not-a-book = ليس كتاب ألوان + .color-book-encrypted = كتاب الألوان هذا مشفر ولا يمكن قراءته + .color-book-empty = لا يحتوي كتاب الألوان على ألوان .cad-color-index-aci-10-249 = فهرس ألوان ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = الرسم متساوي القياس diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index e02e20d0f..89a531b0b 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Най-близък индекс .exact-index-match = Точно съвпадение с индекс + .color-recently-used = Наскоро използвани + .color-most-used = Най-често използвани + .color-number = Номер на цвят + .color-in-drawing = Цветове в този чертеж + .color-sort = Сортиране + .color-no-drawing-colors = Все още няма цветове в този чертеж + .color-books = Цветови каталози + .color-no-books = Няма заредени цветови каталози + .color-load-book = Зареждане на цветови каталог (.acb) + .color-book-label = Цветови каталог + .color-rgb-equivalent = RGB еквивалент + .color-no-match = Няма съвпадащи цветове + .color-not-a-book = Не е цветови каталог + .color-book-encrypted = Този цветови каталог е шифрован и не може да бъде прочетен + .color-book-empty = Цветовият каталог няма цветове .cad-color-index-aci-10-249 = ACI Цветови индекс 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Изометрично чертане diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 544cbc5c6..36bc5f400 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Nejbližší index .exact-index-match = Přesná shoda s indexem + .color-recently-used = Naposledy použité + .color-most-used = Nejpoužívanější + .color-number = Číslo barvy + .color-in-drawing = Barvy v tomto výkresu + .color-sort = Řadit + .color-no-drawing-colors = V tomto výkresu zatím nejsou žádné barvy + .color-books = Barevné vzorníky + .color-no-books = Nejsou načteny žádné barevné vzorníky + .color-load-book = Načíst barevný vzorník (.acb) + .color-book-label = Barevný vzorník + .color-rgb-equivalent = Ekvivalent RGB + .color-no-match = Žádné odpovídající barvy + .color-not-a-book = Nejde o barevný vzorník + .color-book-encrypted = Tento barevný vzorník je zašifrovaný a nelze jej přečíst + .color-book-empty = Barevný vzorník neobsahuje žádné barvy .cad-color-index-aci-10-249 = Barevný index ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Izometrické kreslení diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 8ca17932f..518150aa6 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Nächster Index .exact-index-match = Exakter Index-Treffer + .color-recently-used = Zuletzt verwendet + .color-most-used = Am häufigsten verwendet + .color-number = Farbnummer + .color-in-drawing = Farben in dieser Zeichnung + .color-sort = Sortieren + .color-no-drawing-colors = Noch keine Farben in dieser Zeichnung + .color-books = Farbbücher + .color-no-books = Keine Farbbücher geladen + .color-load-book = Farbbuch laden (.acb) + .color-book-label = Farbbuch + .color-rgb-equivalent = RGB-Entsprechung + .color-no-match = Keine passenden Farben + .color-not-a-book = Kein Farbbuch + .color-book-encrypted = Dieses Farbbuch ist verschlüsselt und kann nicht gelesen werden + .color-book-empty = Das Farbbuch enthält keine Farben .cad-color-index-aci-10-249 = ACI Farbindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrisches Zeichnen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 7c28112e9..9eb330ec7 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1345,6 +1345,21 @@ common = .color-hex = Hex .nearest-index-color = Πλησιέστερος δείκτης .exact-index-match = Ακριβής αντιστοίχιση δείκτη + .color-recently-used = Πρόσφατα χρησιμοποιημένα + .color-most-used = Πιο συχνά χρησιμοποιημένα + .color-number = Αριθμός χρώματος + .color-in-drawing = Χρώματα σε αυτό το σχέδιο + .color-sort = Ταξινόμηση + .color-no-drawing-colors = Δεν υπάρχουν ακόμη χρώματα σε αυτό το σχέδιο + .color-books = Βιβλία χρωμάτων + .color-no-books = Δεν έχουν φορτωθεί βιβλία χρωμάτων + .color-load-book = Φόρτωση βιβλίου χρωμάτων (.acb) + .color-book-label = Βιβλίο χρωμάτων + .color-rgb-equivalent = Ισοδύναμο RGB + .color-no-match = Κανένα χρώμα δεν ταιριάζει + .color-not-a-book = Δεν είναι βιβλίο χρωμάτων + .color-book-encrypted = Αυτό το βιβλίο χρωμάτων είναι κρυπτογραφημένο και δεν μπορεί να διαβαστεί + .color-book-empty = Το βιβλίο χρωμάτων δεν έχει χρώματα .cad-color-index-aci-10-249 = Δείκτης χρώματος ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Ισομετρική σχεδίαση diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 20d9158c5..9c743dcdd 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Nearest index .exact-index-match = Exact index match + .color-recently-used = Recently used + .color-most-used = Most used + .color-number = Color number + .color-in-drawing = Colors in this drawing + .color-sort = Sort + .color-no-drawing-colors = No colors in this drawing yet + .color-books = Color Books + .color-no-books = No color books loaded + .color-load-book = Load color book (.acb) + .color-book-label = Color book + .color-rgb-equivalent = RGB equivalent + .color-no-match = No colors match + .color-not-a-book = Not a color book + .color-book-encrypted = This color book is encrypted and cannot be read + .color-book-empty = The color book has no colors .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .grayscale-250-255 = Grayscale 250–255 .isometric-drafting = Isometric drafting diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 02f7d27aa..a07925a3f 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Índice más cercano .exact-index-match = Coincide con un índice + .color-recently-used = Usados recientemente + .color-most-used = Más usados + .color-number = Número de color + .color-in-drawing = Colores de este dibujo + .color-sort = Ordenar + .color-no-drawing-colors = Aún no hay colores en este dibujo + .color-books = Libros de colores + .color-no-books = No hay libros de colores cargados + .color-load-book = Cargar libro de colores (.acb) + .color-book-label = Libro de colores + .color-rgb-equivalent = Equivalente RGB + .color-no-match = Ningún color coincide + .color-not-a-book = No es un libro de colores + .color-book-encrypted = Este libro de colores está cifrado y no se puede leer + .color-book-empty = El libro de colores no tiene colores .cad-color-index-aci-10-249 = Índice de color ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Dibujo isométrico diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 2cd800ab5..59b67a113 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Lähin indeksi .exact-index-match = Täsmälleen indeksiväri + .color-recently-used = Viimeksi käytetyt + .color-most-used = Eniten käytetyt + .color-number = Värin numero + .color-in-drawing = Tämän piirustuksen värit + .color-sort = Lajittele + .color-no-drawing-colors = Tässä piirustuksessa ei ole vielä värejä + .color-books = Värikirjat + .color-no-books = Värikirjoja ei ole ladattu + .color-load-book = Lataa värikirja (.acb) + .color-book-label = Värikirja + .color-rgb-equivalent = RGB-vastine + .color-no-match = Ei vastaavia värejä + .color-not-a-book = Ei värikirja + .color-book-encrypted = Tämä värikirja on salattu eikä sitä voi lukea + .color-book-empty = Värikirjassa ei ole värejä .cad-color-index-aci-10-249 = ACI väriindeksi 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrinen piirtäminen diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 562ba530d..0068f6c15 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Index le plus proche .exact-index-match = Correspondance exacte d'index + .color-recently-used = Utilisées récemment + .color-most-used = Les plus utilisées + .color-number = Numéro de couleur + .color-in-drawing = Couleurs de ce dessin + .color-sort = Trier + .color-no-drawing-colors = Aucune couleur dans ce dessin pour l'instant + .color-books = Carnets de couleurs + .color-no-books = Aucun carnet de couleurs chargé + .color-load-book = Charger un carnet de couleurs (.acb) + .color-book-label = Carnet de couleurs + .color-rgb-equivalent = Équivalent RVB + .color-no-match = Aucune couleur correspondante + .color-not-a-book = Ce n'est pas un carnet de couleurs + .color-book-encrypted = Ce carnet de couleurs est chiffré et ne peut pas être lu + .color-book-empty = Le carnet de couleurs ne contient aucune couleur .cad-color-index-aci-10-249 = ACI Indice de couleur 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Dessin isométrique diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 38dd1bc64..05aa07fd8 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1332,6 +1332,21 @@ common = .color-hex = हेक्स .nearest-index-color = निकटतम इंडेक्स .exact-index-match = इंडेक्स से सटीक मेल + .color-recently-used = हाल ही में उपयोग किए गए + .color-most-used = सबसे अधिक उपयोग किए गए + .color-number = रंग संख्या + .color-in-drawing = इस ड्रॉइंग के रंग + .color-sort = क्रमबद्ध करें + .color-no-drawing-colors = इस ड्रॉइंग में अभी तक कोई रंग नहीं है + .color-books = रंग पुस्तकें + .color-no-books = कोई रंग पुस्तक लोड नहीं है + .color-load-book = रंग पुस्तक लोड करें (.acb) + .color-book-label = रंग पुस्तक + .color-rgb-equivalent = RGB समतुल्य + .color-no-match = कोई रंग मेल नहीं खाता + .color-not-a-book = यह रंग पुस्तक नहीं है + .color-book-encrypted = यह रंग पुस्तक एन्क्रिप्टेड है और पढ़ी नहीं जा सकती + .color-book-empty = रंग पुस्तक में कोई रंग नहीं है .cad-color-index-aci-10-249 = ACI रंग सूचकांक 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = सममितीय आरेखन diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index c2c689c6f..1e75ed541 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Legközelebbi index .exact-index-match = Pontos indexegyezés + .color-recently-used = Legutóbb használt + .color-most-used = Leggyakrabban használt + .color-number = Színszám + .color-in-drawing = Színek ebben a rajzban + .color-sort = Rendezés + .color-no-drawing-colors = Ebben a rajzban még nincsenek színek + .color-books = Színkönyvek + .color-no-books = Nincs betöltött színkönyv + .color-load-book = Színkönyv betöltése (.acb) + .color-book-label = Színkönyv + .color-rgb-equivalent = RGB-megfelelő + .color-no-match = Nincs egyező szín + .color-not-a-book = Nem színkönyv + .color-book-encrypted = Ez a színkönyv titkosított, és nem olvasható + .color-book-empty = A színkönyv nem tartalmaz színeket .cad-color-index-aci-10-249 = ACI színindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Izometrikus rajzolás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 8af2dad92..241c4f721 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Indice più vicino .exact-index-match = Corrispondenza esatta indice + .color-recently-used = Usati di recente + .color-most-used = Più usati + .color-number = Numero colore + .color-in-drawing = Colori in questo disegno + .color-sort = Ordina + .color-no-drawing-colors = Ancora nessun colore in questo disegno + .color-books = Album colori + .color-no-books = Nessun album colori caricato + .color-load-book = Carica album colori (.acb) + .color-book-label = Album colori + .color-rgb-equivalent = Equivalente RGB + .color-no-match = Nessun colore corrispondente + .color-not-a-book = Non è un album colori + .color-book-encrypted = Questo album colori è crittografato e non può essere letto + .color-book-empty = L'album colori non contiene colori .cad-color-index-aci-10-249 = ACI Indice colori 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Disegno isometrico diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index e221469e4..3e3d9709d 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1334,6 +1334,21 @@ common = .color-hex = 16進 .nearest-index-color = 最も近いインデックス .exact-index-match = インデックスと完全一致 + .color-recently-used = 最近使用した色 + .color-most-used = 使用頻度の高い色 + .color-number = 色番号 + .color-in-drawing = この図面の色 + .color-sort = 並べ替え + .color-no-drawing-colors = この図面にはまだ色がありません + .color-books = カラー ブック + .color-no-books = カラー ブックが読み込まれていません + .color-load-book = カラー ブックを読み込む (.acb) + .color-book-label = カラー ブック + .color-rgb-equivalent = RGB 相当値 + .color-no-match = 一致する色がありません + .color-not-a-book = カラー ブックではありません + .color-book-encrypted = このカラー ブックは暗号化されているため読み込めません + .color-book-empty = カラー ブックに色がありません .cad-color-index-aci-10-249 = ACI カラーインデックス 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = アイソメ図作成 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 931a8f746..0c6364171 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = 16진수 .nearest-index-color = 가장 가까운 인덱스 .exact-index-match = 인덱스와 정확히 일치 + .color-recently-used = 최근 사용 + .color-most-used = 가장 많이 사용 + .color-number = 색상 번호 + .color-in-drawing = 이 도면의 색상 + .color-sort = 정렬 + .color-no-drawing-colors = 이 도면에는 아직 색상이 없습니다 + .color-books = 색상표 + .color-no-books = 로드된 색상표가 없습니다 + .color-load-book = 색상표 로드 (.acb) + .color-book-label = 색상표 + .color-rgb-equivalent = RGB 등가 + .color-no-match = 일치하는 색상이 없습니다 + .color-not-a-book = 색상표가 아닙니다 + .color-book-encrypted = 이 색상표는 암호화되어 있어 읽을 수 없습니다 + .color-book-empty = 색상표에 색상이 없습니다 .cad-color-index-aci-10-249 = ACI 컬러 인덱스 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 등각 제도 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 49f805744..859458ff0 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1349,6 +1349,21 @@ common = .color-hex = Hex .nearest-index-color = ດັດຊະນີທີ່ໃກ້ທີ່ສຸດ .exact-index-match = ກົງກັບດັດຊະນີພໍດີ + .color-recently-used = ໃຊ້ຫຼ້າສຸດ + .color-most-used = ໃຊ້ຫຼາຍທີ່ສຸດ + .color-number = ເລກສີ + .color-in-drawing = ສີໃນແບບແຕ້ມນີ້ + .color-sort = ຈັດລຽງ + .color-no-drawing-colors = ຍັງບໍ່ມີສີໃນແບບແຕ້ມນີ້ + .color-books = ປຶ້ມສີ + .color-no-books = ບໍ່ໄດ້ໂຫຼດປຶ້ມສີ + .color-load-book = ໂຫຼດປຶ້ມສີ (.acb) + .color-book-label = ປຶ້ມສີ + .color-rgb-equivalent = ທຽບເທົ່າ RGB + .color-no-match = ບໍ່ມີສີທີ່ກົງກັນ + .color-not-a-book = ບໍ່ແມ່ນປຶ້ມສີ + .color-book-encrypted = ປຶ້ມສີນີ້ຖືກເຂົ້າລະຫັດ ແລະອ່ານບໍ່ໄດ້ + .color-book-empty = ປຶ້ມສີບໍ່ມີສີ .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .grayscale-250-255 = ລະດັບສີເທົາ 250–255 .isometric-drafting = ການຂຽນແບບໄອໂຊເມຕຣິກ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index fb4af2b56..f7d735880 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1342,6 +1342,21 @@ common = .color-hex = Hex .nearest-index-color = Dichtstbijzijnde index .exact-index-match = Exacte indexkleur + .color-recently-used = Recent gebruikt + .color-most-used = Meest gebruikt + .color-number = Kleurnummer + .color-in-drawing = Kleuren in deze tekening + .color-sort = Sorteren + .color-no-drawing-colors = Nog geen kleuren in deze tekening + .color-books = Kleurboeken + .color-no-books = Geen kleurboeken geladen + .color-load-book = Kleurboek laden (.acb) + .color-book-label = Kleurboek + .color-rgb-equivalent = RGB-equivalent + .color-no-match = Geen kleuren gevonden + .color-not-a-book = Geen kleurboek + .color-book-encrypted = Dit kleurboek is versleuteld en kan niet worden gelezen + .color-book-empty = Het kleurboek bevat geen kleuren .cad-color-index-aci-10-249 = ACI Kleurindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrisch tekenen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index cc06b8293..0852391c9 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = Hex .nearest-index-color = Najbliższy indeks .exact-index-match = Dokładne dopasowanie indeksu + .color-recently-used = Ostatnio używane + .color-most-used = Najczęściej używane + .color-number = Numer koloru + .color-in-drawing = Kolory w tym rysunku + .color-sort = Sortuj + .color-no-drawing-colors = Brak kolorów w tym rysunku + .color-books = Wzorniki kolorów + .color-no-books = Nie wczytano wzorników kolorów + .color-load-book = Wczytaj wzornik kolorów (.acb) + .color-book-label = Wzornik kolorów + .color-rgb-equivalent = Odpowiednik RGB + .color-no-match = Brak pasujących kolorów + .color-not-a-book = To nie jest wzornik kolorów + .color-book-encrypted = Ten wzornik kolorów jest zaszyfrowany i nie można go odczytać + .color-book-empty = Wzornik kolorów nie zawiera kolorów .cad-color-index-aci-10-249 = Indeks kolorów ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Rysowanie izometryczne diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 3b8aedc44..33fa9e2e8 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1344,6 +1344,21 @@ common = .color-hex = Hex .nearest-index-color = Índice mais próximo .exact-index-match = Corresponde exatamente ao índice + .color-recently-used = Usadas recentemente + .color-most-used = Mais usadas + .color-number = Número da cor + .color-in-drawing = Cores neste desenho + .color-sort = Ordenar + .color-no-drawing-colors = Ainda não há cores neste desenho + .color-books = Catálogos de cores + .color-no-books = Nenhum catálogo de cores carregado + .color-load-book = Carregar catálogo de cores (.acb) + .color-book-label = Catálogo de cores + .color-rgb-equivalent = Equivalente RGB + .color-no-match = Nenhuma cor corresponde + .color-not-a-book = Não é um catálogo de cores + .color-book-encrypted = Este catálogo de cores é criptografado e não pode ser lido + .color-book-empty = O catálogo de cores não tem cores .cad-color-index-aci-10-249 = Índice de cor ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Desenho isométrico diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index c8e255413..4778249cf 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1347,6 +1347,21 @@ common = .color-hex = Hex .nearest-index-color = Ближайший индекс .exact-index-match = Точное совпадение с индексом + .color-recently-used = Недавно использованные + .color-most-used = Чаще всего используемые + .color-number = Номер цвета + .color-in-drawing = Цвета в этом чертеже + .color-sort = Сортировка + .color-no-drawing-colors = В этом чертеже пока нет цветов + .color-books = Альбомы цветов + .color-no-books = Альбомы цветов не загружены + .color-load-book = Загрузить альбом цветов (.acb) + .color-book-label = Альбом цветов + .color-rgb-equivalent = Эквивалент RGB + .color-no-match = Нет подходящих цветов + .color-not-a-book = Это не альбом цветов + .color-book-encrypted = Этот альбом цветов зашифрован и не может быть прочитан + .color-book-empty = В альбоме цветов нет цветов .cad-color-index-aci-10-249 = Индекс цвета ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Изометрическое черчение diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index a2fe8529d..c94918f97 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1330,6 +1330,21 @@ common = .color-hex = Hex .nearest-index-color = En yakın indeks .exact-index-match = Tam indeks eşleşmesi + .color-recently-used = Son kullanılanlar + .color-most-used = En çok kullanılanlar + .color-number = Renk numarası + .color-in-drawing = Bu çizimdeki renkler + .color-sort = Sırala + .color-no-drawing-colors = Bu çizimde henüz renk yok + .color-books = Renk kitapları + .color-no-books = Yüklenmiş renk kitabı yok + .color-load-book = Renk kitabı yükle (.acb) + .color-book-label = Renk kitabı + .color-rgb-equivalent = RGB karşılığı + .color-no-match = Eşleşen renk yok + .color-not-a-book = Bir renk kitabı değil + .color-book-encrypted = Bu renk kitabı şifreli ve okunamıyor + .color-book-empty = Renk kitabında renk yok .cad-color-index-aci-10-249 = ACI renk dizini 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = İzometrik çizim diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index af02d48a4..d14c18f2c 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1331,6 +1331,21 @@ common = .color-hex = 十六进制 .nearest-index-color = 最接近的索引色 .exact-index-match = 与索引色完全匹配 + .color-recently-used = 最近使用 + .color-most-used = 最常使用 + .color-number = 颜色编号 + .color-in-drawing = 此图形中的颜色 + .color-sort = 排序 + .color-no-drawing-colors = 此图形中还没有颜色 + .color-books = 配色系统 + .color-no-books = 未加载配色系统 + .color-load-book = 加载配色系统 (.acb) + .color-book-label = 配色系统 + .color-rgb-equivalent = RGB 等效值 + .color-no-match = 没有匹配的颜色 + .color-not-a-book = 不是配色系统 + .color-book-encrypted = 此配色系统已加密,无法读取 + .color-book-empty = 配色系统中没有颜色 .cad-color-index-aci-10-249 = ACI 颜色索引 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 等轴测绘图 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 11b4868a6..fec601b75 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1343,6 +1343,21 @@ common = .color-hex = 十六進位 .nearest-index-color = 最接近的索引色 .exact-index-match = 與索引色完全相符 + .color-recently-used = 最近使用 + .color-most-used = 最常使用 + .color-number = 顏色編號 + .color-in-drawing = 此圖面中的顏色 + .color-sort = 排序 + .color-no-drawing-colors = 此圖面中還沒有顏色 + .color-books = 配色系統 + .color-no-books = 未載入配色系統 + .color-load-book = 載入配色系統 (.acb) + .color-book-label = 配色系統 + .color-rgb-equivalent = RGB 等效值 + .color-no-match = 沒有相符的顏色 + .color-not-a-book = 不是配色系統 + .color-book-encrypted = 此配色系統已加密,無法讀取 + .color-book-empty = 配色系統中沒有顏色 .cad-color-index-aci-10-249 = ACI 顏色索引 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 等角製圖 diff --git a/src/app/document.rs b/src/app/document.rs index bf0b130b7..8c3a33df8 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -277,6 +277,8 @@ pub(super) struct DocumentTab { /// Model-space corners of the last crossing window drawn with no command /// running: STRETCH on that selection stretches through it (#1068). pub(super) last_crossing_window: Option<([f64; 2], [f64; 2])>, + /// Colours picked in this drawing this session, newest first. Not saved. + pub(super) recent_colors: Vec, } impl DocumentTab { @@ -679,6 +681,7 @@ impl DocumentTab { suspended_cmd: None, transparent_resume: false, last_crossing_window: None, + recent_colors: Vec::new(), } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 73e7b8c63..3906e3dc1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -965,9 +965,12 @@ pub(super) struct OpenCADStudio { // ── Floating panel windows ──────────────────────────────────────────── /// The open Select Color panel, if any. color_picker: Option, + /// Colour books loaded this session, read lazily from `color_book_paths`. + color_books: std::sync::Arc>, + color_book_paths: Vec, + color_books_read: bool, /// Recently committed real colours, newest first. /// ACI and True Color remain semantically distinct even when their RGB matches. - recent_colors: Vec, /// The open in-canvas modal dialog, if any (Plan B: shared overlay instead /// of OS windows). active_modal: Option, @@ -4168,8 +4171,11 @@ pub enum Message { ColorPicker(crate::ui::color_select::ColorPickerMsg), /// Close the colour picker without choosing. CloseColorPicker, - /// Commit the colour chosen in the shared picker. - ColorWindowPick(codec::types::Color), + /// Commit the colour chosen in the shared picker, with its `BOOK$NAME` + /// when it comes from a colour book. + ColorWindowPick(codec::types::Color, Option), + /// An `.acb` file was read: the book and, on desktop, where it lives. + ColorBookLoaded(Result<(crate::ui::color_select::book::ColorBook, Option), String>), /// Set a block/linetype Handle field on the selected dim style from a /// dropdown of available block-records / linetypes (by name). DsSetHandle { @@ -4601,7 +4607,9 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] web_autosave_clear_after: Vec::new(), color_picker: None, - recent_colors: Vec::new(), + color_books: Default::default(), + color_book_paths: Vec::new(), + color_books_read: false, active_modal: None, hyperlink_editor_handles: Vec::new(), hyperlink_editor_url: String::new(), diff --git a/src/app/settings.rs b/src/app/settings.rs index b62c6b072..2a3795ce2 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -619,6 +619,9 @@ pub struct UserSettings { /// Blocks palette: library folders or drawings, last used first. #[serde(default)] pub block_libraries: Vec, + /// Colour book (.acb) files loaded in the Select Color panel. + #[serde(default)] + pub color_books: Vec, /// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons, /// 4 details, 5 list. #[serde(default = "default_block_view")] @@ -901,6 +904,7 @@ impl Default for UserSettings { block_recent: Vec::new(), block_favorites: Vec::new(), block_libraries: Vec::new(), + color_books: Vec::new(), block_palette_view: 1, block_insert: BlockInsertOptions::default(), block_mru_list: 50, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 6032c3b2d..082199c2a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -860,6 +860,7 @@ impl OpenCADStudio { block_recent: self.block_palette.recent.clone(), block_favorites: self.block_palette.favorites.clone(), block_libraries: self.block_palette.libraries.clone(), + color_books: self.color_book_paths.clone(), block_palette_view: self.block_palette.view as u8, block_insert: self.block_palette.options.clone(), block_mru_list: self.block_mru_list, @@ -1025,6 +1026,7 @@ impl OpenCADStudio { self.block_palette.recent = s.block_recent.clone(); self.block_palette.favorites = s.block_favorites.clone(); self.block_palette.libraries = s.block_libraries.clone(); + self.color_book_paths = s.color_books.clone(); self.block_palette.view = crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view); self.block_palette.set_options(s.block_insert.clone()); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c467d0468..8830f12a4 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4180,38 +4180,7 @@ impl OpenCADStudio { Task::none() } - Message::LayerColorSet(color) => { - let i = self.active_tab; - // Apply to every selected layer (multi-select), not just one. - let names = self.selected_layer_names(i); - if !names.is_empty() { - let undo = self.begin_layer_undo(i, "LAYER COLOR", &names); - for name in &names { - if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { - dl.color = color; - dl.color_name = None; - dl.book_name = None; - } - } - for pl in self.tabs[i].layers.layers.iter_mut() { - if names.contains(&pl.name) { - pl.color = color; - pl.color_name = None; - pl.book_name = None; - } - } - self.tabs[i].dirty = true; - self.commit_layer_undo(i, undo); - // ByLayer color is baked into the cached wires at - // tessellation time, so bump the geometry epoch to - // invalidate the wire cache and repaint with the new color. - self.tabs[i].scene.invalidate_layer_dependencies(&names); - self.tabs[i].layers.color_picker_row = None; - self.tabs[i].layers.color_full_palette = false; - self.sync_ribbon_layers(); - } - Task::none() - } + Message::LayerColorSet(color) => self.set_layer_color(color, None), Message::LayerLinetypeSet(lt) => { let i = self.active_tab; @@ -7264,22 +7233,7 @@ impl OpenCADStudio { Task::none() } - Message::PropColorChanged(color) => { - let i = self.active_tab; - let handles = self.property_target_handles(i); - if handles.is_empty() { - let task = self.on_ribbon_color_changed(color); - self.refresh_properties(); - return task; - } - self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, color); - } - }); - self.tabs[i].properties.color_picker_open = false; - Task::none() - } + Message::PropColorChanged(color) => self.set_property_color(color, None), Message::PropLwChanged(lw) => { let i = self.active_tab; @@ -11605,10 +11559,19 @@ impl OpenCADStudio { ) { self.graphic_attributes.open_menu = None; } - let (current, varies) = self.color_picker_current(&target, color); - self.color_picker = Some(crate::ui::color_select::ColorPicker::new( - target, current, varies, - )); + let (current, varies, name) = self.color_picker_current(&target, color); + let mut picker = crate::ui::color_select::ColorPicker::new(target, current, varies); + if !matches!(picker.target, crate::app::ColorPickTarget::Interface(_)) { + self.read_color_books(); + let tab = &self.tabs[self.active_tab]; + picker = picker.with_drawing( + crate::ui::color_select::document::collect(&tab.scene.document), + &tab.recent_colors, + std::sync::Arc::clone(&self.color_books), + name, + ); + } + self.color_picker = Some(picker); self.modal_offset = iced::Vector::ZERO; self.ds_color_open = None; self.mls_color_open = None; @@ -11628,9 +11591,28 @@ impl OpenCADStudio { return Task::none(); }; match picker.update(msg) { - Some(crate::ui::color_select::Outcome::Commit(color)) => { - self.update(Message::ColorWindowPick(color)) + Some(crate::ui::color_select::Outcome::Commit(color, name)) => { + self.update(Message::ColorWindowPick(color, name)) } + Some(crate::ui::color_select::Outcome::LoadBook) => Task::perform( + async { + let file = crate::sys::file_dialog() + .add_filter(crate::t!("Color book").as_ref(), &["acb", "ACB"]) + .pick_file() + .await + .ok_or_else(|| "Cancelled".to_string())?; + let book = crate::ui::color_select::book::parse( + &String::from_utf8_lossy(&file.read().await), + )?; + // The web build has no paths to read again later. + #[cfg(not(target_arch = "wasm32"))] + let path = Some(crate::sys::handle_path(&file).display().to_string()); + #[cfg(target_arch = "wasm32")] + let path = None; + Ok((book, path)) + }, + Message::ColorBookLoaded, + ), Some(crate::ui::color_select::Outcome::Cancel) => { self.color_picker = None; Task::none() @@ -11656,7 +11638,7 @@ impl OpenCADStudio { Task::none() } - Message::ColorWindowPick(color) => { + Message::ColorWindowPick(color, name) => { // Interface colours are not drawing colours: keep them out of // the recent list. if !matches!( @@ -11665,7 +11647,35 @@ impl OpenCADStudio { ) { self.note_recent_color(color); } - self.on_color_window_pick(color) + self.on_color_window_pick(color, name) + } + Message::ColorBookLoaded(Ok((book, path))) => { + // A book loaded again replaces the earlier copy. + let books = std::sync::Arc::make_mut(&mut self.color_books); + let index = match books.iter().position(|b| b.name == book.name) { + Some(index) => { + books[index] = book; + index + } + None => { + books.push(book); + books.len() - 1 + } + }; + if let Some(path) = path.filter(|path| !self.color_book_paths.contains(path)) { + self.color_book_paths.push(path); + self.save_config(); + } + if let Some(picker) = self.color_picker.as_mut() { + picker.show_book(std::sync::Arc::clone(&self.color_books), index); + } + Task::none() + } + Message::ColorBookLoaded(Err(error)) => { + if let Some(picker) = self.color_picker.as_mut().filter(|_| error != "Cancelled") { + picker.set_status(error); + } + Task::none() } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } @@ -11674,16 +11684,33 @@ impl OpenCADStudio { /// The colour the Select Color panel shows as "Current", and whether the /// selection it edits has mixed colours. A mixed selection starts from the /// colour of its most recently drawn object (the highest handle). + /// Also returns the colour-book identity (`BOOK$NAME`) of that colour. fn color_picker_current( &self, target: &crate::app::ColorPickTarget, color: codec::types::Color, - ) -> (codec::types::Color, bool) { - if !matches!(target, crate::app::ColorPickTarget::Properties) { - return (color, false); - } + ) -> (codec::types::Color, bool, Option) { let i = self.active_tab; let document = &self.tabs[i].scene.document; + let name_of = |handle| { + document + .get_entity(handle) + .and_then(|entity| entity.common().color_name.clone()) + }; + match target { + crate::app::ColorPickTarget::Properties => {} + crate::app::ColorPickTarget::Layer(layer) => { + let name = document.layers.get(layer).and_then(|layer| { + match (&layer.book_name, &layer.color_name) { + (Some(book), Some(name)) => Some(format!("{book}${name}")), + (None, Some(name)) => Some(name.clone()), + _ => None, + } + }); + return (color, false, name); + } + _ => return (color, false, None), + } let mut colors = self .property_target_handles(i) .into_iter() @@ -11693,15 +11720,99 @@ impl OpenCADStudio { .map(|entity| (handle, entity.common().color)) }) .collect::>(); - let Some(&(_, first)) = colors.first() else { - return (color, false); + let Some(&(first_handle, first)) = colors.first() else { + return (color, false, None); }; if colors.iter().all(|&(_, c)| c == first) { - return (first, false); + return (first, false, name_of(first_handle)); } colors.sort_by_key(|&(handle, _)| handle); - let newest = colors.last().map_or(color, |&(_, c)| c); - (newest, true) + let (newest_handle, newest) = colors.last().copied().unwrap_or((first_handle, color)); + (newest, true, name_of(newest_handle)) + } + + /// Read the colour books remembered in the settings, once per session. + fn read_color_books(&mut self) { + if std::mem::replace(&mut self.color_books_read, true) { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let books: Vec<_> = self + .color_book_paths + .iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .filter_map(|text| crate::ui::color_select::book::parse(&text).ok()) + .collect(); + self.color_books = std::sync::Arc::new(books); + } + } + + /// Colour the selected objects (or set the current colour when nothing + /// is selected). `name` is a colour-book identity, `BOOK$NAME`. + pub(super) fn set_property_color( + &mut self, + color: codec::types::Color, + name: Option, + ) -> Task { + let i = self.active_tab; + let handles = self.property_target_handles(i); + if handles.is_empty() { + let task = self.on_ribbon_color_changed(color); + self.refresh_properties(); + return task; + } + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_named_color(entity, color, name.clone()); + } + }); + self.tabs[i].properties.color_picker_open = false; + Task::none() + } + + /// Colour every selected layer (multi-select), not just one. `name` is a + /// colour-book identity, `BOOK$NAME`. + pub(super) fn set_layer_color( + &mut self, + color: codec::types::Color, + name: Option<&str>, + ) -> Task { + let i = self.active_tab; + // Layers keep the book and the colour name apart. + let (book_name, color_name) = match name.map(|name| name.split_once('$')) { + Some(Some((book, name))) => (Some(book), Some(name)), + Some(None) => (None, name), + None => (None, None), + }; + let names = self.selected_layer_names(i); + if !names.is_empty() { + let undo = self.begin_layer_undo(i, "LAYER COLOR", &names); + for name in &names { + if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { + dl.color = color; + dl.color_name = color_name.map(str::to_string); + dl.book_name = book_name.map(str::to_string); + } + } + for pl in self.tabs[i].layers.layers.iter_mut() { + if names.contains(&pl.name) { + pl.color = color; + pl.color_name = color_name.map(str::to_string); + pl.book_name = book_name.map(str::to_string); + } + } + self.tabs[i].dirty = true; + self.commit_layer_undo(i, undo); + // ByLayer color is baked into the cached wires at + // tessellation time, so bump the geometry epoch to + // invalidate the wire cache and repaint with the new color. + self.tabs[i].scene.invalidate_layer_dependencies(&names); + self.tabs[i].layers.color_picker_row = None; + self.tabs[i].layers.color_full_palette = false; + self.sync_ribbon_layers(); + } + Task::none() } pub(crate) fn note_recent_color(&mut self, color: codec::types::Color) { @@ -11712,12 +11823,13 @@ impl OpenCADStudio { codec::types::Color::Index(_) | codec::types::Color::Rgb { .. } ) { // No duplicates: selecting an existing colour moves it to the front. - if let Some(pos) = self.recent_colors.iter().position(|c| c == &color) { - self.recent_colors.remove(pos); + let recent = &mut self.tabs[self.active_tab].recent_colors; + if let Some(pos) = recent.iter().position(|c| c == &color) { + recent.remove(pos); } - self.recent_colors.insert(0, color); - self.recent_colors.truncate(12); + recent.insert(0, color); + recent.truncate(12); } } diff --git a/src/app/update/style.rs b/src/app/update/style.rs index bb944373d..fc0db8af7 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1136,7 +1136,11 @@ impl OpenCADStudio { Task::none() } - pub(super) fn on_color_window_pick(&mut self, color: codec::types::Color) -> Task { + pub(super) fn on_color_window_pick( + &mut self, + color: codec::types::Color, + name: Option, + ) -> Task { if let Some(crate::app::ColorPickTarget::Interface(target)) = self .color_picker .as_ref() @@ -1201,7 +1205,9 @@ impl OpenCADStudio { field: f, value: s, }), - Some(crate::app::ColorPickTarget::Properties) => Some(Message::PropColorChanged(color)), + Some(crate::app::ColorPickTarget::Properties) => { + return self.set_property_color(color, name); + } Some(crate::app::ColorPickTarget::PropertiesBg) => { Some(Message::PropBgColorChanged(color)) } @@ -1210,12 +1216,12 @@ impl OpenCADStudio { } Some(crate::app::ColorPickTarget::MText) => Some(Message::MTextColorChanged(color)), Some(crate::app::ColorPickTarget::Ribbon) => Some(Message::RibbonColorChanged(color)), - Some(crate::app::ColorPickTarget::Layer(name)) => { + Some(crate::app::ColorPickTarget::Layer(layer)) => { let i = self.active_tab; - match self.layer_panel_index(i, &name) { + match self.layer_panel_index(i, &layer) { Some(idx) => { self.tabs[i].layers.selected = Some(idx); - Some(Message::LayerColorSet(color)) + return self.set_layer_color(color, name.as_deref()); } None => None, } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index db73b3a84..8b41da05c 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2187,7 +2187,7 @@ bg={bg_ms:.1}ms n={view_count}" &redo_labels, self.win_size, self.tabs[self.active_tab].is_start, - &self.recent_colors, + &self.tabs[self.active_tab].recent_colors, ) .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()) }; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 7fd608a1d..18ecb5ed0 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4720,6 +4720,21 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Hex" => Some(("common", "color-hex")), "Nearest index" => Some(("common", "nearest-index-color")), "Exact index match" => Some(("common", "exact-index-match")), + "Recently used" => Some(("common", "color-recently-used")), + "Most used" => Some(("common", "color-most-used")), + "Color number" => Some(("common", "color-number")), + "Colors in this drawing" => Some(("common", "color-in-drawing")), + "Sort" => Some(("common", "color-sort")), + "No colors in this drawing yet" => Some(("common", "color-no-drawing-colors")), + "Color Books" => Some(("common", "color-books")), + "No color books loaded" => Some(("common", "color-no-books")), + "Load color book (.acb)" => Some(("common", "color-load-book")), + "Color book" => Some(("common", "color-book-label")), + "RGB equivalent" => Some(("common", "color-rgb-equivalent")), + "No colors match" => Some(("common", "color-no-match")), + "Not a color book" => Some(("common", "color-not-a-book")), + "This color book is encrypted and cannot be read" => Some(("common", "color-book-encrypted")), + "The color book has no colors" => Some(("common", "color-book-empty")), "CAD Color Index (ACI) 10–249" => Some(("common", "cad-color-index-aci-10-249")), "Grayscale 250–255" => Some(("common", "grayscale-250-255")), "Isometric drafting" => Some(("common", "isometric-drafting")), diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index 927f6877b..0178f1cd2 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -322,6 +322,12 @@ pub fn apply_color(entity: &mut EntityType, color: AcadColor) { common.color_book_handle = None; } +/// Like [`apply_color`], but keeps a colour-book identity (`BOOK$NAME`). +pub fn apply_named_color(entity: &mut EntityType, color: AcadColor, name: Option) { + apply_color(entity, color); + entity.common_mut().color_name = name; +} + pub fn apply_line_weight(entity: &mut EntityType, lw: LineWeight) { entity.as_entity_mut().set_line_weight(lw); } diff --git a/src/ui/color_select/book.rs b/src/ui/color_select/book.rs new file mode 100644 index 000000000..f06cf8e3e --- /dev/null +++ b/src/ui/color_select/book.rs @@ -0,0 +1,176 @@ +//! Colour books: AutoCAD `.acb` files. +//! +//! An `.acb` is XML: a `bookName` and `colorPage`s of `colorEntry`s, each a +//! `colorName` and an `RGB8` value. Books from some vendors encrypt their +//! values; those cannot be read. + +/// One named colour. +#[derive(Debug, Clone, PartialEq)] +pub struct BookColor { + pub name: String, + pub rgb: (u8, u8, u8), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ColorBook { + pub name: String, + /// Every colour, page after page. + pub colors: Vec, + /// Where each page starts in `colors`. + page_starts: Vec, +} + +/// Pages for a book without `colorPage`s, as AutoCAD shows them. +const PAGE_SIZE: usize = 7; + +impl ColorBook { + pub fn page_count(&self) -> usize { + self.page_starts.len() + } + + /// The colours of one page, and the index of its first colour. + pub fn page(&self, page: usize) -> (usize, &[BookColor]) { + let start = self.page_starts.get(page).copied().unwrap_or(0); + let end = self + .page_starts + .get(page + 1) + .copied() + .unwrap_or(self.colors.len()); + (start, &self.colors[start..end]) + } + + /// The page holding colour `index`. + pub fn page_of(&self, index: usize) -> usize { + self.page_starts + .partition_point(|&start| start <= index) + .saturating_sub(1) + } + + /// The colour with this name, as stored after the `$` of `BOOK$NAME`. + pub fn find(&self, name: &str) -> Option { + self.colors.iter().position(|c| c.name == name) + } + + /// The `BOOK$NAME` identity objects and layers store. + pub fn identity(&self, index: usize) -> String { + format!("{}${}", self.name, self.colors[index].name) + } +} + +/// Read an `.acb` file's text. +pub fn parse(text: &str) -> Result { + let doc = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) + .map_err(|e| e.to_string())?; + let root = doc.root_element(); + if !root.has_tag_name("colorBook") { + return Err(crate::t!("Not a color book").into_owned()); + } + let mut book = ColorBook { + name: text_of(root, "bookName").unwrap_or_default().to_string(), + colors: Vec::new(), + page_starts: Vec::new(), + }; + let mut encrypted = false; + let mut read_entries = |parent: roxmltree::Node<'_, '_>, book: &mut ColorBook| { + for entry in parent.children().filter(|n| n.has_tag_name("colorEntry")) { + let rgb = child(entry, "RGB8").and_then(|rgb| { + let channel = |tag| text_of(rgb, tag)?.parse::().ok(); + Some((channel("red")?, channel("green")?, channel("blue")?)) + }); + match (text_of(entry, "colorName"), rgb) { + (Some(name), Some(rgb)) => book.colors.push(BookColor { + name: name.to_string(), + rgb, + }), + _ => encrypted = true, + } + } + }; + let mut pages = root + .children() + .filter(|n| n.has_tag_name("colorPage")) + .peekable(); + if pages.peek().is_some() { + for page in pages { + let start = book.colors.len(); + read_entries(page, &mut book); + if book.colors.len() > start { + book.page_starts.push(start); + } + } + } else { + read_entries(root, &mut book); + book.page_starts = (0..book.colors.len()).step_by(PAGE_SIZE).collect(); + } + if book.colors.is_empty() { + return Err(if encrypted { + crate::t!("This color book is encrypted and cannot be read") + } else { + crate::t!("The color book has no colors") + } + .into_owned()); + } + Ok(book) +} + +fn child<'a, 'i>(node: roxmltree::Node<'a, 'i>, tag: &str) -> Option> { + node.children().find(|n| n.has_tag_name(tag)) +} + +fn text_of<'a>(node: roxmltree::Node<'a, '_>, tag: &str) -> Option<&'a str> { + child(node, tag).and_then(|n| n.text()).map(str::trim) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str, (r, g, b): (u8, u8, u8)) -> String { + format!( + "{name}\ + {r}{g}{b}" + ) + } + + #[test] + fn reads_pages_and_colours() { + let text = format!( + "\u{feff}RAL CLASSIC\ + 123{}{}\ + {}", + entry("RAL 1000", (190, 189, 127)), + entry("RAL 1001", (194, 176, 120)), + entry("RAL 5015", (34, 113, 179)), + ); + let book = parse(&text).unwrap(); + assert_eq!(book.name, "RAL CLASSIC"); + assert_eq!(book.page_count(), 2); + assert_eq!(book.page(1), (2, &book.colors[2..])); + assert_eq!(book.page_of(1), 0); + assert_eq!(book.page_of(2), 1); + let blue = book.find("RAL 5015").unwrap(); + assert_eq!(book.colors[blue].rgb, (34, 113, 179)); + assert_eq!(book.identity(blue), "RAL CLASSIC$RAL 5015"); + } + + #[test] + fn a_book_without_pages_gets_pages_of_seven() { + let entries: String = (0..9).map(|i| entry(&format!("C{i}"), (i, i, i))).collect(); + let book = parse(&format!( + "B{entries}" + )) + .unwrap(); + assert_eq!(book.page_count(), 2); + assert_eq!(book.page(1).1.len(), 2); + } + + #[test] + fn rejects_unreadable_books() { + assert!(parse("not xml").is_err()); + assert!(parse("").is_err()); + let encrypted = "P\ + XAbC\ + "; + assert!(parse(encrypted).is_err()); + } +} diff --git a/src/ui/color_select/document.rs b/src/ui/color_select/document.rs new file mode 100644 index 000000000..9f976b66d --- /dev/null +++ b/src/ui/color_select/document.rs @@ -0,0 +1,233 @@ +//! The colours a drawing uses, for the Document tab. +//! +//! One pass over every object (block definitions included) and every layer +//! when the panel opens. Keys borrow from the drawing, so the pass allocates +//! only per distinct colour, never per object. + +use super::math; +use codec::types::{Color as AcadColor, Handle}; +use rustc_hash::FxHashMap; + +/// One distinct colour: a concrete colour plus, for a colour-book colour, +/// its `BOOK$NAME` identity. +#[derive(Debug, Clone, PartialEq)] +pub struct DocColor { + pub color: AcadColor, + pub name: Option, + /// Objects and layers using it. Only used for sorting, never shown. + uses: u32, + /// The newest object or layer using it. + newest: Handle, +} + +impl DocColor { + /// The few characters drawn on the swatch: the index number, `RGB`, or + /// the first word of the colour book. + pub fn tag(&self) -> String { + match (&self.name, self.color) { + (Some(name), _) => name + .split(['$', ' ']) + .next() + .unwrap_or_default() + .chars() + .take(5) + .collect(), + (None, AcadColor::Index(i)) => i.to_string(), + _ => "RGB".to_string(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DocSort { + #[default] + Recent, + Usage, + Hue, + Number, +} + +impl DocSort { + pub const ALL: [DocSort; 4] = [ + DocSort::Recent, + DocSort::Usage, + DocSort::Hue, + DocSort::Number, + ]; +} + +impl std::fmt::Display for DocSort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + DocSort::Recent => crate::t!("Recently used"), + DocSort::Usage => crate::t!("Most used"), + DocSort::Hue => crate::t!("Color"), + DocSort::Number => crate::t!("Color number"), + }) + } +} + +/// A colour as a hash key: index colours keep their number, true colours +/// sit above 255. +fn key(color: AcadColor) -> Option { + match color { + AcadColor::Index(i) => Some(u32::from(i)), + AcadColor::Rgb { r, g, b } => { + Some(1 << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b)) + } + _ => None, + } +} + +/// Every concrete colour in the drawing. ByLayer, ByBlock and None are +/// states rather than colours and are skipped. +pub fn collect(document: &codec::CadDocument) -> Vec { + // Layers keep book and colour name apart; join them like objects do. + let layer_names: Vec> = document + .layers + .iter() + .map(|layer| match (&layer.book_name, &layer.color_name) { + (Some(book), Some(name)) => Some(format!("{book}${name}")), + (None, Some(name)) => Some(name.clone()), + _ => None, + }) + .collect(); + let objects = document.entities().map(|entity| { + let common = entity.common(); + (common.color, common.color_name.as_deref(), common.handle) + }); + let layers = document + .layers + .iter() + .zip(&layer_names) + .map(|(layer, name)| (layer.color, name.as_deref(), layer.handle)); + tally(objects.chain(layers)) +} + +fn tally<'a>(uses: impl Iterator, Handle)>) -> Vec { + let mut index: FxHashMap<(u32, Option<&str>), usize> = FxHashMap::default(); + let mut colors: Vec = Vec::new(); + for (color, name, handle) in uses { + let Some(k) = key(color) else { continue }; + let name = name.filter(|name| !name.is_empty()); + let slot = *index.entry((k, name)).or_insert_with(|| { + colors.push(DocColor { + color, + name: name.map(str::to_string), + uses: 0, + newest: handle, + }); + colors.len() - 1 + }); + let entry = &mut colors[slot]; + entry.uses += 1; + entry.newest = entry.newest.max(handle); + } + colors +} + +/// Sort for display. `recent` is this drawing's pick history, newest first; +/// colours not picked this session follow, newest object first. +pub fn sort(colors: &mut [DocColor], by: DocSort, recent: &[AcadColor]) { + let rank = |c: &DocColor| { + recent + .iter() + .position(|&r| r == c.color) + .unwrap_or(usize::MAX) + }; + match by { + DocSort::Recent => { + colors.sort_by(|a, b| rank(a).cmp(&rank(b)).then(b.newest.cmp(&a.newest))) + } + DocSort::Usage => colors.sort_by(|a, b| b.uses.cmp(&a.uses).then(b.newest.cmp(&a.newest))), + DocSort::Hue => colors.sort_by_cached_key(|c| { + let (r, g, b) = math::color_rgb(c.color).unwrap_or_default(); + let hsb = math::rgb_to_hsb(r, g, b); + // Grays after the colours; within a hue band, light to dark. + let gray = hsb.s < 0.12 || hsb.b < 0.08; + let band = if gray { + 0 + } else { + (hsb.h / 15.0).round() as u32 + }; + (gray, band, std::cmp::Reverse((hsb.b * 1000.0) as u32)) + }), + DocSort::Number => colors.sort_by(|a, b| { + let kind = |c: &DocColor| match (&c.name, c.color) { + (None, AcadColor::Index(_)) => 0, + (None, _) => 1, + _ => 2, + }; + kind(a) + .cmp(&kind(b)) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| key(a.color).cmp(&key(b.color))) + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RED: AcadColor = AcadColor::Index(1); + const BLUE: AcadColor = AcadColor::Index(5); + const SKY: AcadColor = AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }; + + fn sample() -> Vec { + let h = Handle::new; + tally( + [ + (RED, None, h(1)), + (RED, None, h(2)), + (RED, None, h(3)), + (AcadColor::ByLayer, None, h(4)), + (SKY, None, h(5)), + (SKY, Some("RAL CLASSIC$RAL 5015"), h(6)), + (BLUE, Some(""), h(7)), + ] + .into_iter(), + ) + } + + #[test] + fn collects_each_concrete_colour_once() { + let colors = sample(); + assert_eq!(colors.len(), 4, "ByLayer skipped, book colour kept apart"); + assert_eq!(colors[0].uses, 3); + assert_eq!(colors[0].newest, Handle::new(3)); + assert_eq!(colors[3].name, None, "an empty name is no name"); + assert_eq!(colors[2].tag(), "RAL"); + assert_eq!(colors[0].tag(), "1"); + assert_eq!(colors[1].tag(), "RGB"); + } + + #[test] + fn sorts_four_ways() { + let order = |by, recent: &[AcadColor]| { + let mut colors = sample(); + sort(&mut colors, by, recent); + colors + .iter() + .map(|c| (c.color, c.name.is_some())) + .collect::>() + }; + // Picked this session first, then newest object first. + assert_eq!( + order(DocSort::Recent, &[RED]), + [(RED, false), (BLUE, false), (SKY, true), (SKY, false)] + ); + assert_eq!(order(DocSort::Usage, &[])[0], (RED, false)); + assert_eq!( + order(DocSort::Number, &[]), + [(RED, false), (BLUE, false), (SKY, false), (SKY, true)] + ); + // Red (hue 0) before sky blue (hue ~199) before blue (hue 240). + assert_eq!(order(DocSort::Hue, &[])[0], (RED, false)); + assert_eq!(order(DocSort::Hue, &[])[3], (BLUE, false)); + } +} diff --git a/src/ui/color_select/mod.rs b/src/ui/color_select/mod.rs index adcefdf2f..6a1f752f6 100644 --- a/src/ui/color_select/mod.rs +++ b/src/ui/color_select/mod.rs @@ -3,6 +3,8 @@ //! properties panel and every style editor so colour selection looks and //! behaves the same everywhere. +pub mod book; +pub mod document; pub mod editor; pub mod eyedropper; pub mod math; diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index ccd63b85d..f78f870b0 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -5,6 +5,8 @@ //! Opened for an interface colour (any colour in Options) it shows only the //! colour editor: no tabs, no index colours. +use super::book::ColorBook; +use super::document::{self, DocColor, DocSort}; use super::editor::{rgb_color, ColorEditor, EditorMsg}; use super::eyedropper::{Eyedropper, EyedropperMsg}; use super::math; @@ -12,20 +14,29 @@ use crate::app::{ArrowKey, ColorPickTarget, Message}; use crate::t; use crate::ui::properties::acad_color_display; use codec::types::Color as AcadColor; -use iced::widget::{button, column, container, row, text, Space}; +use iced::widget::{ + button, column, container, mouse_area, pick_list, row, scrollable, svg, text, text_input, Space, +}; use iced::{Background, Border, Color, Element, Length, Theme}; +use std::sync::Arc; const PANEL_WIDTH: f32 = 400.0; /// Scope for Tab / Shift+Tab, so focus cycles through the panel's own /// fields instead of the dialogs and command line underneath. const PANEL_ID: &str = "select-color-panel"; const BODY_HEIGHT: f32 = 352.0; +/// Swatches per row in the Document tab. +const DOC_COLUMNS: usize = 8; +/// Search matches shown at once in the Color books tab. +const PAGE_BARS: usize = 7; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ColorPickerTab { + Document, #[default] Index, TrueColor, + Books, } #[derive(Debug, Clone)] @@ -40,17 +51,32 @@ pub enum ColorPickerMsg { UseNearest, Arrow(ArrowKey), Eyedropper(EyedropperMsg), + /// A colour of the Document tab, by its place in the list. + PickDocument(usize), + DocSort(DocSort), + /// Show another loaded colour book. + Book(usize), + BookPage(usize), + /// Scrolling over the page: one page per notch. + BookScroll(f32), + BookQuery(String), + /// A colour of the shown book, by its index in the book. + PickBook(usize), + LoadBook, Ok, Cancel, } /// What the caller has to do after an update. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum Outcome { - Commit(AcadColor), + /// The colour, and its `BOOK$NAME` when it comes from a colour book. + Commit(AcadColor, Option), Cancel, /// Capture the window for the eyedropper. Screenshot, + /// Ask for an `.acb` file. + LoadBook, } pub struct ColorPicker { @@ -60,9 +86,22 @@ pub struct ColorPicker { current: AcadColor, varies: bool, pending: AcadColor, + /// `BOOK$NAME` of the current and the new colour, for colour-book colours. + current_name: Option, + pending_name: Option, tab: ColorPickerTab, editor: ColorEditor, eyedropper: Option, + doc: Vec, + doc_sort: DocSort, + /// This drawing's colours picked this session, newest first. + recent: Vec, + books: Arc>, + book: usize, + book_page: usize, + book_query: String, + /// Why the last colour book did not load. + status: Option, } impl ColorPicker { @@ -77,12 +116,85 @@ impl ColorPicker { current, varies, pending: current, + current_name: None, + pending_name: None, tab, editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))), eyedropper: None, + doc: Vec::new(), + doc_sort: DocSort::default(), + recent: Vec::new(), + books: Arc::default(), + book: 0, + book_page: 0, + book_query: String::new(), + status: None, } } + /// Fill the Document and Color books tabs. `current_name` is the + /// `BOOK$NAME` of the current colour; a colour from a loaded book opens + /// on its page. + pub fn with_drawing( + mut self, + mut doc: Vec, + recent: &[AcadColor], + books: Arc>, + current_name: Option, + ) -> Self { + document::sort(&mut doc, self.doc_sort, recent); + self.doc = doc; + self.recent = recent.to_vec(); + self.books = books; + if let Some(name) = current_name.filter(|name| !name.is_empty()) { + if self.show_identity(&name) { + self.tab = ColorPickerTab::Books; + } + self.current_name = Some(name.clone()); + self.pending_name = Some(name); + } + self + } + + /// The loaded books changed; show book `index`. + pub fn show_book(&mut self, books: Arc>, index: usize) { + self.books = books; + self.book = index; + self.book_page = 0; + self.book_query.clear(); + self.status = None; + self.tab = ColorPickerTab::Books; + } + + pub fn set_status(&mut self, status: String) { + self.status = Some(status); + } + + /// Turn to the page holding `BOOK$NAME`. False when no loaded book has it. + fn show_identity(&mut self, identity: &str) -> bool { + let Some((book_name, name)) = identity.split_once('$') else { + return false; + }; + for (i, book) in self.books.iter().enumerate() { + if let Some(color) = book.find(name).filter(|_| book.name == book_name) { + self.book = i; + self.book_page = book.page_of(color); + return true; + } + } + false + } + + /// The colour of the shown book that is the new colour, if any. + fn book_selection(&self) -> Option { + let (book_name, name) = self.pending_name.as_deref()?.split_once('$')?; + let book = self + .books + .get(self.book) + .filter(|book| book.name == book_name)?; + book.find(name) + } + pub fn pending(&self) -> AcadColor { self.pending } @@ -98,6 +210,7 @@ impl ColorPicker { fn set_pending(&mut self, color: AcadColor) { self.pending = color; + self.pending_name = None; if let Some(rgb) = math::color_rgb(color) { self.editor.set_rgb(rgb); } @@ -131,24 +244,82 @@ impl ColorPicker { match msg { ColorPickerMsg::Tab(tab) => self.tab = tab, ColorPickerMsg::Pick(color) => self.set_pending(color), - ColorPickerMsg::Revert => self.set_pending(self.current), + ColorPickerMsg::Revert => { + self.set_pending(self.current); + self.pending_name = self.current_name.clone(); + } ColorPickerMsg::Editor(editor) => { if self.editor.update(editor) { let (r, g, b) = self.editor.rgb(); self.pending = AcadColor::Rgb { r, g, b }; + self.pending_name = None; } } ColorPickerMsg::UseNearest => { let (r, g, b) = self.rgb(); self.set_pending(AcadColor::Index(math::nearest_aci(r, g, b))); } - ColorPickerMsg::Arrow(direction) => { - if let (ColorPickerTab::Index, AcadColor::Index(index)) = (self.tab, self.pending) { + ColorPickerMsg::Arrow(direction) => match (self.tab, self.pending) { + (ColorPickerTab::Index, AcadColor::Index(index)) => { if let Some(next) = step_index(index, direction) { self.set_pending(AcadColor::Index(next)); } } + (ColorPickerTab::Document, _) => { + let at = self + .doc + .iter() + .position(|c| c.color == self.pending && c.name == self.pending_name); + if let Some(next) = step_list(at, self.doc.len(), DOC_COLUMNS, direction) { + self.update(ColorPickerMsg::PickDocument(next)); + } + } + (ColorPickerTab::Books, _) => { + let len = self + .books + .get(self.book) + .map_or(0, |book| book.colors.len()); + if let Some(next) = step_list(self.book_selection(), len, 1, direction) { + self.pick_book(next); + } + } + _ => {} + }, + ColorPickerMsg::PickDocument(i) => { + if let Some(entry) = self.doc.get(i) { + let (color, name) = (entry.color, entry.name.clone()); + self.set_pending(color); + self.pending_name = name; + } } + ColorPickerMsg::DocSort(by) => { + self.doc_sort = by; + document::sort(&mut self.doc, by, &self.recent); + } + ColorPickerMsg::Book(i) => { + if i < self.books.len() { + self.book = i; + self.book_page = 0; + self.book_query.clear(); + } + } + ColorPickerMsg::BookPage(page) => { + if let Some(book) = self.books.get(self.book) { + self.book_page = page.min(book.page_count().saturating_sub(1)); + self.book_query.clear(); + } + } + ColorPickerMsg::BookScroll(delta) => { + let page = if delta < 0.0 { + self.book_page + 1 + } else { + self.book_page.saturating_sub(1) + }; + self.update(ColorPickerMsg::BookPage(page)); + } + ColorPickerMsg::BookQuery(query) => self.book_query = query, + ColorPickerMsg::PickBook(i) => self.pick_book(i), + ColorPickerMsg::LoadBook => return Some(Outcome::LoadBook), ColorPickerMsg::Eyedropper(EyedropperMsg::Start) => { self.eyedropper = Some(Eyedropper::default()); } @@ -156,13 +327,26 @@ impl ColorPicker { ColorPickerMsg::Ok => { // Apply a value still being typed, as if Enter had been pressed. self.update(ColorPickerMsg::Editor(EditorMsg::HexSubmit)); - return Some(Outcome::Commit(self.pending)); + return Some(Outcome::Commit(self.pending, self.pending_name.clone())); } ColorPickerMsg::Cancel => return Some(Outcome::Cancel), } None } + fn pick_book(&mut self, index: usize) { + let books = Arc::clone(&self.books); + let Some(book) = books.get(self.book) else { + return; + }; + if let Some(color) = book.colors.get(index) { + let (r, g, b) = color.rgb; + self.set_pending(AcadColor::Rgb { r, g, b }); + self.pending_name = Some(book.identity(index)); + self.book_page = book.page_of(index); + } + } + /// The full-window eyedropper layer, while it is active. pub fn eyedropper_view(&self) -> Option> { self.eyedropper.as_ref().map(Eyedropper::view) @@ -170,19 +354,33 @@ impl ColorPicker { pub fn view(&self) -> Element<'_, Message> { let editor_only = self.editor_only(); - let body = if editor_only || self.tab == ColorPickerTab::TrueColor { - self.true_color_tab() - } else { - self.index_tab() + let body = match self.tab { + _ if editor_only => self.true_color_tab(), + ColorPickerTab::Document => self.document_tab(), + ColorPickerTab::Index => self.index_tab(), + ColorPickerTab::TrueColor => self.true_color_tab(), + ColorPickerTab::Books => self.books_tab(), }; // The interface picker shrinks to the editor; the CAD panel keeps one // height across its tabs, with the info line at the bottom. let mut body = column![body].spacing(8); if !editor_only { - body = body.push(Space::new().height(Length::Fill)); + // Document and Color books fill the height themselves. + if matches!(self.tab, ColorPickerTab::Index | ColorPickerTab::TrueColor) { + body = body.push(Space::new().height(Length::Fill)); + } let fill = math::color_rgb(self.pending).map_or(Color::from_rgb8(128, 128, 128), rgb_color); + let info = match (&self.status, &self.pending_name) { + (Some(status), _) => status.clone(), + (None, Some(name)) => { + let (r, g, b) = math::color_rgb(self.pending).unwrap_or_default(); + format!("{name} · RGB {r}, {g}, {b}") + } + (None, None) => math::describe(self.pending), + }; + let error = self.status.is_some(); body = body .push( container(Space::new()) @@ -196,9 +394,11 @@ impl ColorPicker { .width(12) .height(12) .style(move |_theme: &Theme| swatch_style(fill)), - text(math::describe(self.pending)) - .size(11) - .font(iced::Font::MONOSPACE), + text(info).size(11).font(iced::Font::MONOSPACE).style( + move |theme: &Theme| text::Style { + color: error.then(|| theme.palette().danger.base.color), + } + ), ] .spacing(8) .align_y(iced::Center), @@ -247,8 +447,10 @@ impl ColorPicker { }; container( row![ + tab(t!("Document").into_owned(), ColorPickerTab::Document), tab(t!("Index Color").into_owned(), ColorPickerTab::Index), tab(t!("True Color").into_owned(), ColorPickerTab::TrueColor), + tab(t!("Color Books").into_owned(), ColorPickerTab::Books), ] .spacing(2), ) @@ -261,11 +463,7 @@ impl ColorPicker { /// under the middle of the tab bar. Clicking Current reverts. fn preview_row(&self) -> Element<'_, Message> { let half = |label: String, value: String, fill: Color| { - let ink = if ink_is_dark(fill) { - Color::from_rgb8(0x11, 0x12, 0x14) - } else { - Color::WHITE - }; + let ink = ink(fill); column![ text(label).size(11).color(ink.scale_alpha(0.85)), Space::new().height(Length::Fill), @@ -277,10 +475,18 @@ impl ColorPicker { }; let (current_bg, _) = acad_color_display(self.current); let (new_bg, _) = acad_color_display(self.pending); + // A colour-book colour shows its own name, without the book. + let name = |color: AcadColor, identity: &Option| match identity { + Some(identity) => identity + .split_once('$') + .map_or(identity.as_str(), |(_, name)| name) + .to_string(), + None => math::short_name(color), + }; let current_value = if self.varies { "*VARIES*".to_string() } else { - math::short_name(self.current) + name(self.current, &self.current_name) }; let current = button(half(t!("Current").into_owned(), current_value, current_bg)) .on_press(picker_msg(ColorPickerMsg::Revert)) @@ -301,7 +507,7 @@ impl ColorPicker { .style(|_theme: &Theme| filled(Color::from_rgba(0.0, 0.0, 0.0, 0.35))); let new = container(half( t!("New").into_owned(), - math::short_name(self.pending), + name(self.pending, &self.pending_name), new_bg, )) .style(move |_theme: &Theme| container::Style { @@ -364,29 +570,272 @@ impl ColorPicker { button(Space::new().width(width).height(height)) .on_press(picker_msg(ColorPickerMsg::Pick(AcadColor::Index(index)))) .padding(0) - .style(move |theme: &Theme, status| { - let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); - button::Style { - background: Some(fill.into()), + .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) + .into() + } + + fn document_tab(&self) -> Element<'_, Message> { + let header = row![ + text(t!("Colors in this drawing")).size(11), + text(format!("· {}", self.doc.len())) + .size(11) + .style(muted_text), + Space::new().width(Length::Fill), + text(t!("Sort")).size(11).style(muted_text), + pick_list(Some(self.doc_sort), &DocSort::ALL[..], DocSort::to_string) + .on_select(|by| picker_msg(ColorPickerMsg::DocSort(by))) + .text_size(12) + .padding([3, 6]), + ] + .spacing(6) + .align_y(iced::Center); + if self.doc.is_empty() { + return column![ + header, + empty_note(t!("No colors in this drawing yet").into_owned()) + ] + .spacing(10) + .into(); + } + let grid = self.doc.chunks(DOC_COLUMNS).enumerate().fold( + column![].spacing(4), + |grid, (r, chunk)| { + let line = chunk + .iter() + .enumerate() + .fold(row![].spacing(4), |line, (c, entry)| { + line.push(self.doc_swatch(r * DOC_COLUMNS + c, entry)) + }); + let line = (chunk.len()..DOC_COLUMNS) + .fold(line, |line, _| line.push(Space::new().width(Length::Fill))); + grid.push(line) + }, + ); + column![ + header, + scrollable(grid).height(Length::Fill).spacing(4).direction( + scrollable::Direction::Vertical( + scrollable::Scrollbar::new().width(4).scroller_width(4) + ) + ), + ] + .spacing(10) + .into() + } + + fn doc_swatch<'a>(&self, i: usize, entry: &'a DocColor) -> Element<'a, Message> { + let fill = math::color_rgb(entry.color).map_or(Color::from_rgb8(128, 128, 128), rgb_color); + let selected = self.pending == entry.color && self.pending_name == entry.name; + button( + container( + text(entry.tag()) + .size(10) + .font(iced::Font::MONOSPACE) + .color(ink(fill)), + ) + .align_y(iced::Bottom) + .padding([1, 4]) + .height(Length::Fill), + ) + .on_press(picker_msg(ColorPickerMsg::PickDocument(i))) + .padding(0) + .width(Length::Fill) + .height(30) + .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) + .into() + } + + fn books_tab(&self) -> Element<'_, Message> { + let load_button = |content: Element<'static, Message>| { + button(content) + .on_press(picker_msg(ColorPickerMsg::LoadBook)) + .style(button::secondary) + }; + let Some(book) = self.books.get(self.book) else { + return container( + column![ + text(t!("No color books loaded")).size(12).style(muted_text), + load_button(text(t!("Load color book (.acb)")).size(12).into()) + .padding([6, 14]), + ] + .spacing(10) + .align_x(iced::Center), + ) + .center(Length::Fill) + .into(); + }; + let label = |label: std::borrow::Cow<'static, str>| text(label).size(11).style(muted_text); + let books = Arc::clone(&self.books); + let top = row![ + column![ + label(t!("Color book")), + pick_list( + Some(self.book), + (0..self.books.len()).collect::>(), + move |&i| books[i].name.clone(), + ) + .on_select(|i| picker_msg(ColorPickerMsg::Book(i))) + .text_size(12) + .padding([4, 6]) + .width(Length::Fill), + ] + .spacing(4) + .width(Length::Fill), + column![ + label(t!("Find")), + text_input("", &self.book_query) + .on_input(|query| picker_msg(ColorPickerMsg::BookQuery(query))) + .size(12) + .padding([5, 8]) + .font(iced::Font::MONOSPACE) + .width(116), + ] + .spacing(4), + load_button( + svg(crate::ui::icons::themed_handle( + crate::ui::icons::FOLDER_OPEN + )) + .width(15) + .height(15) + .into() + ) + .padding([6, 7]), + ] + .spacing(8) + .align_y(iced::Bottom); + + // The page's colours, or the first matches while searching. + let selected = self.book_selection(); + let query = self.book_query.trim().to_lowercase(); + let mut bars = column![].spacing(2).height(Length::Fill); + let mut shown = 0; + if query.is_empty() { + let (start, colors) = book.page(self.book_page); + for (i, color) in colors.iter().enumerate() { + bars = bars.push(book_bar(start + i, color, selected)); + shown += 1; + } + } else { + for (i, color) in book + .colors + .iter() + .enumerate() + .filter(|(_, color)| color.name.to_lowercase().contains(&query)) + .take(PAGE_BARS) + { + bars = bars.push(book_bar(i, color, selected)); + shown += 1; + } + } + let bars: Element<'_, Message> = if shown == 0 { + empty_note(t!("No colors match").into_owned()) + } else { + mouse_area(bars) + .on_scroll(|delta| { + let (iced::mouse::ScrollDelta::Lines { y, .. } + | iced::mouse::ScrollDelta::Pixels { y, .. }) = delta; + picker_msg(ColorPickerMsg::BookScroll(y)) + }) + .into() + }; + + // Every page as one band of its middle colour; the shown page framed. + let strip = (0..book.page_count()).fold(column![], |strip, page| { + let (_, colors) = book.page(page); + let fill = rgb_color(colors[colors.len() / 2].rgb); + let shown = query.is_empty() && page == self.book_page; + strip.push( + button(Space::new()) + .on_press(picker_msg(ColorPickerMsg::BookPage(page))) + .padding(0) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme: &Theme, _status| button::Style { + background: Some(fill.into()), + border: Border { + color: Color::WHITE, + width: if shown { 2.0 } else { 0.0 }, + radius: 2.0.into(), + }, + ..Default::default() + }), + ) + }); + let arrow = |path: &'static str, page: usize| { + button( + svg(svg::Handle::from_memory( + format!( + "\ + " + ) + .into_bytes(), + )) + .width(9) + .height(5) + .style(|theme: &Theme, _status| svg::Style { + color: Some(theme.palette().background.base.text), + }), + ) + .on_press(picker_msg(ColorPickerMsg::BookPage(page))) + .width(Length::Fill) + .height(18) + .padding([6, 8]) + .style(button::secondary) + }; + let strip = column![ + arrow("M0 4L3.5 0L7 4z", self.book_page.saturating_sub(1)), + container(strip) + .height(Length::Fill) + .padding(1) + .style(|theme: &Theme| container::Style { border: Border { - color: if selected || hovered { - theme.palette().primary.base.color - } else { - Color::from_rgba(0.0, 0.0, 0.0, 0.35) - }, - width: if selected { - 2.5 - } else if hovered { - 1.5 - } else { - 1.0 - }, - radius: 2.0.into(), + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), }, ..Default::default() - } - }) - .into() + }), + arrow("M0 0L7 0L3.5 4z", self.book_page + 1), + ] + .spacing(3) + .width(26); + + // RGB equivalent of the selected colour. + let chosen = selected.map(|i| &book.colors[i]); + let channel = |name: std::borrow::Cow<'static, str>, value: Option| { + row![ + text(name).size(12).style(muted_text), + Space::new().width(Length::Fill), + text(value.map_or("—".to_string(), |v| v.to_string())) + .size(12) + .font(iced::Font::MONOSPACE), + ] + }; + let equivalent = column![ + text(t!("RGB equivalent")).size(11), + channel(t!("Red"), chosen.map(|c| c.rgb.0)), + channel(t!("Green"), chosen.map(|c| c.rgb.1)), + channel(t!("Blue"), chosen.map(|c| c.rgb.2)), + container(Space::new()) + .width(Length::Fill) + .height(1) + .style(|theme: &Theme| filled(theme.palette().background.weak.color)), + text(format!("{} / {}", self.book_page + 1, book.page_count())) + .size(11) + .style(muted_text), + text(chosen.map_or(String::new(), |c| c.name.clone())).size(12), + ] + .spacing(6) + .width(104); + + column![ + top, + row![bars, strip, equivalent] + .spacing(10) + .height(Length::Fill) + ] + .spacing(12) + .into() } fn true_color_tab(&self) -> Element<'_, Message> { @@ -494,6 +943,21 @@ fn step_index(index: u8, direction: ArrowKey) -> Option { } } +/// Arrow keys over a list laid out `columns` wide. Nothing selected yet +/// starts at the first entry. +fn step_list(at: Option, len: usize, columns: usize, direction: ArrowKey) -> Option { + let Some(at) = at else { + return (len > 0).then_some(0); + }; + let next = match direction { + ArrowKey::Left => at.checked_sub(1), + ArrowKey::Right => Some(at + 1), + ArrowKey::Up => at.checked_sub(columns), + ArrowKey::Down => Some(at + columns), + }; + next.filter(|&next| next < len) +} + /// Move keyboard focus to the next (or previous) field inside the panel. pub fn focus_step(forward: bool) -> iced::Task { use iced::advanced::widget::operation::{focusable, scope}; @@ -514,6 +978,77 @@ fn ink_is_dark(color: Color) -> bool { math::ink_is_dark(r, g, b) } +/// Text that reads on top of `fill`. +fn ink(fill: Color) -> Color { + if ink_is_dark(fill) { + Color::from_rgb8(0x11, 0x12, 0x14) + } else { + Color::WHITE + } +} + +/// One colour of a colour book: a full-width bar with its name. +fn book_bar( + i: usize, + color: &super::book::BookColor, + selected: Option, +) -> Element<'_, Message> { + let fill = rgb_color(color.rgb); + button( + container( + text(&color.name) + .size(12) + .font(iced::Font::MONOSPACE) + .color(ink(fill)), + ) + .center(Length::Fill), + ) + .on_press(picker_msg(ColorPickerMsg::PickBook(i))) + .padding(0) + .width(Length::Fill) + .height(Length::Fill) + .style(move |theme: &Theme, status| { + swatch_button_style(theme, status, fill, selected == Some(i)) + }) + .into() +} + +/// A short gray note in the middle of the free space. +fn empty_note<'a>(note: String) -> Element<'a, Message> { + container(text(note).size(12).style(muted_text)) + .center(Length::Fill) + .into() +} + +/// A clickable colour: ringed in the accent colour when selected or hovered. +fn swatch_button_style( + theme: &Theme, + status: button::Status, + fill: Color, + selected: bool, +) -> button::Style { + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + button::Style { + background: Some(fill.into()), + border: Border { + color: if selected || hovered { + theme.palette().primary.base.color + } else { + Color::from_rgba(0.0, 0.0, 0.0, 0.35) + }, + width: if selected { + 2.5 + } else if hovered { + 1.5 + } else { + 1.0 + }, + radius: 2.0.into(), + }, + ..Default::default() + } +} + fn filled(color: Color) -> container::Style { container::Style { background: Some(color.into()), @@ -641,7 +1176,7 @@ mod tests { p.update(ColorPickerMsg::Pick(AcadColor::Index(5))); assert_eq!( p.update(ColorPickerMsg::Ok), - Some(Outcome::Commit(AcadColor::Index(5))) + Some(Outcome::Commit(AcadColor::Index(5), None)) ); assert_eq!(p.update(ColorPickerMsg::Cancel), Some(Outcome::Cancel)); } @@ -652,11 +1187,14 @@ mod tests { edit(&mut p, EditorMsg::HexInput("#3bf".into())); assert_eq!( p.update(ColorPickerMsg::Ok), - Some(Outcome::Commit(AcadColor::Rgb { - r: 0x33, - g: 0xBB, - b: 0xFF - })) + Some(Outcome::Commit( + AcadColor::Rgb { + r: 0x33, + g: 0xBB, + b: 0xFF + }, + None + )) ); } @@ -724,6 +1262,89 @@ mod tests { ); } + fn ral() -> Arc> { + let entry = |name: &str, (r, g, b): (u8, u8, u8)| { + format!( + "{name}{r}\ + {g}{b}" + ) + }; + let text = format!( + "RAL CLASSIC{}{}\ + {}", + entry("RAL 1000", (190, 189, 127)), + entry("RAL 3020", (204, 6, 5)), + entry("RAL 5015", (34, 113, 179)), + ); + Arc::new(vec![super::super::book::parse(&text).unwrap()]) + } + + #[test] + fn book_colours_keep_their_name_until_edited() { + let mut p = picker(AcadColor::Index(30)).with_drawing(Vec::new(), &[], ral(), None); + p.update(ColorPickerMsg::PickBook(2)); + assert_eq!(p.book_page, 1, "turns to the colour's page"); + assert_eq!( + p.update(ColorPickerMsg::Ok), + Some(Outcome::Commit( + AcadColor::Rgb { + r: 34, + g: 113, + b: 179 + }, + Some("RAL CLASSIC$RAL 5015".into()) + )) + ); + p.update(ColorPickerMsg::Arrow(ArrowKey::Up)); + assert_eq!(p.pending_name.as_deref(), Some("RAL CLASSIC$RAL 3020")); + edit(&mut p, EditorMsg::Hue(120.0)); + assert_eq!( + p.pending_name, None, + "an edited colour is no longer the book's" + ); + } + + #[test] + fn opens_a_book_colour_on_its_page() { + let p = picker(AcadColor::Rgb { + r: 34, + g: 113, + b: 179, + }) + .with_drawing(Vec::new(), &[], ral(), Some("RAL CLASSIC$RAL 5015".into())); + assert_eq!(p.tab(), ColorPickerTab::Books); + assert_eq!(p.book_selection(), Some(2)); + assert_eq!(p.book_page, 1); + } + + #[test] + fn document_colours_pick_and_walk() { + // A new drawing's layer 0 is white. + let doc = document::collect(&codec::CadDocument::new()); + assert!(doc.iter().any(|c| c.color == AcadColor::Index(7))); + let mut p = picker(AcadColor::Index(30)).with_drawing(doc, &[], Arc::default(), None); + p.update(ColorPickerMsg::Tab(ColorPickerTab::Document)); + p.update(ColorPickerMsg::Arrow(ArrowKey::Right)); + assert_eq!( + p.pending(), + p.doc[0].color, + "the first key press starts at the top" + ); + + assert_eq!(step_list(None, 3, 8, ArrowKey::Down), Some(0)); + assert_eq!(step_list(Some(1), 20, 8, ArrowKey::Down), Some(9)); + assert_eq!(step_list(Some(1), 5, 8, ArrowKey::Down), None); + assert_eq!(step_list(Some(0), 5, 8, ArrowKey::Left), None); + } + + #[test] + fn loading_a_book_is_left_to_the_app() { + let mut p = picker(AcadColor::Index(30)); + assert_eq!(p.update(ColorPickerMsg::LoadBook), Some(Outcome::LoadBook)); + p.show_book(ral(), 0); + assert_eq!(p.tab(), ColorPickerTab::Books); + } + // ── Headless UI tests ─────────────────────────────────────────────── // // These run the real widgets through iced's simulator. Setting @@ -809,7 +1430,31 @@ mod tests { ColorPickerTab, EditorMode, bool, - ); 7] = [ + ); 10] = [ + ( + "document", + ColorPickTarget::Properties, + AcadColor::Index(7), + ColorPickerTab::Document, + EditorMode::Hsb, + false, + ), + ( + "books", + ColorPickTarget::Properties, + AcadColor::Index(30), + ColorPickerTab::Books, + EditorMode::Hsb, + false, + ), + ( + "no-books", + ColorPickTarget::Properties, + AcadColor::Index(30), + ColorPickerTab::Books, + EditorMode::Hsb, + false, + ), ( "index", ColorPickTarget::Properties, @@ -876,8 +1521,21 @@ mod tests { crate::ui::style::fusion_theme::fusion_white(), ] { for (name, target, color, tab, mode, varies) in views.clone() { - let mut p = ColorPicker::new(target, color, varies); + let books = if name == "no-books" { + Arc::default() + } else { + ral() + }; + let mut p = ColorPicker::new(target, color, varies).with_drawing( + document::collect(&codec::CadDocument::new()), + &[], + books, + None, + ); p.update(ColorPickerMsg::Tab(tab)); + if name == "books" { + p.update(ColorPickerMsg::PickBook(1)); + } edit(&mut p, EditorMsg::Mode(mode)); let mut ui = simulator(&p); let snapshot = ui.snapshot(&theme).expect("snapshot"); From 60dc633db135c9df32808b1ca899ea518ce4b7d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:56:57 +0000 Subject: [PATCH 15/32] Select Color: press the arrow on the Color books tab in the book test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/picker.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index f78f870b0..a4313f881 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -1295,6 +1295,7 @@ mod tests { 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)); From 00b925b9fadd80de0dc9854ae64c07add22e38ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:36:21 +0000 Subject: [PATCH 16/32] Select Color: built-in RAL CLASSIC and CSS COLORS color books Two AutoCAD .acb books ship with the panel and are read the first time it opens in a session; a loaded book of the same name replaces them. RAL values are approximate (CC BY-SA 4.0, Wikipedia); sources and licences are in each file's header and assets/color_books/README.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- assets/color_books/README.md | 13 ++ assets/color_books/css-colors.acb | 202 +++++++++++++++++++ assets/color_books/ral-classic.acb | 301 +++++++++++++++++++++++++++++ src/app/update/mod.rs | 24 ++- src/ui/color_select/book.rs | 11 ++ 5 files changed, 543 insertions(+), 8 deletions(-) create mode 100644 assets/color_books/README.md create mode 100644 assets/color_books/css-colors.acb create mode 100644 assets/color_books/ral-classic.acb diff --git a/assets/color_books/README.md b/assets/color_books/README.md new file mode 100644 index 000000000..d20dbd5f6 --- /dev/null +++ b/assets/color_books/README.md @@ -0,0 +1,13 @@ +# 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 | Licence | +|---|---|---| +| `ral-classic.acb` | RAL CLASSIC, 216 approximate sRGB values | CC BY-SA 4.0, adapted from Wikipedia, "List of RAL colours" | +| `css-colors.acb` | CSS COLORS, the 148 CSS named colours | Values from the W3C CSS Color Module Level 4 | + +RAL is a registered trademark of RAL gGmbH. The RAL values are approximate +screen representations, not colour-binding references, and are not +affiliated with, endorsed by or licensed by RAL gGmbH. diff --git a/assets/color_books/css-colors.acb b/assets/color_books/css-colors.acb new file mode 100644 index 000000000..0868c4b83 --- /dev/null +++ b/assets/color_books/css-colors.acb @@ -0,0 +1,202 @@ + + + + CSS COLORS + 1 + 0 + + mistyrose255228225 + red25500 + salmon250128114 + lightcoral240128128 + indianred2059292 + rosybrown188143143 + firebrick1783434 + + + brown1654242 + darkred13900 + maroon12800 + coral25512780 + lightsalmon255160122 + orangered255690 + tomato2559971 + + + darksalmon233150122 + sienna1608245 + bisque255228196 + blanchedalmond255235205 + darkorange2551400 + navajowhite255222173 + papayawhip255239213 + + + peachpuff255218185 + antiquewhite250235215 + sandybrown24416496 + burlywood222184135 + chocolate21010530 + tan210180140 + peru20513363 + + + saddlebrown1396919 + cornsilk255248220 + gold2552150 + moccasin255228181 + orange2551650 + wheat245222179 + goldenrod21816532 + + + darkgoldenrod18413411 + lemonchiffon255250205 + lightyellow255255224 + yellow2552550 + lightgoldenrodyellow250250210 + beige245245220 + khaki240230140 + + + palegoldenrod238232170 + darkkhaki189183107 + olive1281280 + yellowgreen15420550 + olivedrab10714235 + darkolivegreen8510747 + chartreuse1272550 + + + greenyellow17325547 + lawngreen1242520 + lime02550 + palegreen152251152 + lightgreen144238144 + limegreen5020550 + darkseagreen143188143 + + + forestgreen3413934 + green01280 + darkgreen01000 + springgreen0255127 + mediumspringgreen0250154 + mediumseagreen60179113 + seagreen4613987 + + + aquamarine127255212 + mediumaquamarine102205170 + aqua0255255 + cyan0255255 + lightcyan224255255 + paleturquoise175238238 + powderblue176224230 + + + turquoise64224208 + darkturquoise0206209 + mediumturquoise72209204 + lightseagreen32178170 + cadetblue95158160 + darkcyan0139139 + teal0128128 + + + darkslategray477979 + darkslategrey477979 + deepskyblue0191255 + skyblue135206235 + lightblue173216230 + dodgerblue30144255 + lightskyblue135206250 + + + lightsteelblue176196222 + steelblue70130180 + lightslategray119136153 + lightslategrey119136153 + slategray112128144 + slategrey112128144 + cornflowerblue100149237 + + + royalblue65105225 + blue00255 + mediumblue00205 + darkblue00139 + navy00128 + midnightblue2525112 + mediumslateblue123104238 + + + mediumpurple147112219 + slateblue10690205 + darkslateblue7261139 + blueviolet13843226 + rebeccapurple10251153 + indigo750130 + darkviolet1480211 + + + mediumorchid18685211 + darkorchid15350204 + fuchsia2550255 + magenta2550255 + violet238130238 + plum221160221 + orchid218112214 + + + thistle216191216 + darkmagenta1390139 + purple1280128 + mediumvioletred19921133 + deeppink25520147 + hotpink255105180 + lightpink255182193 + + + pink255192203 + crimson2202060 + palevioletred219112147 + aliceblue240248255 + azure240255255 + floralwhite255250240 + ghostwhite248248255 + + + honeydew240255240 + ivory255255240 + lavenderblush255240245 + mintcream245255250 + seashell255245238 + snow255250250 + white255255255 + + + oldlace253245230 + lavender230230250 + linen250240230 + whitesmoke245245245 + gainsboro220220220 + lightgray211211211 + lightgrey211211211 + + + silver192192192 + darkgray169169169 + darkgrey169169169 + gray128128128 + grey128128128 + dimgray105105105 + dimgrey105105105 + + + black000 + + 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/src/app/update/mod.rs b/src/app/update/mod.rs index 8830f12a4..1211eff5b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11731,21 +11731,29 @@ impl OpenCADStudio { (newest, true, name_of(newest_handle)) } - /// Read the colour books remembered in the settings, once per session. + /// Read the built-in colour books and the ones remembered in the + /// settings, once per session. A loaded book 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; } + use crate::ui::color_select::book; + #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] + let mut books: Vec<_> = book::builtin().collect(); #[cfg(not(target_arch = "wasm32"))] + for loaded in self + .color_book_paths + .iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .filter_map(|text| book::parse(&text).ok()) { - let books: Vec<_> = self - .color_book_paths - .iter() - .filter_map(|path| std::fs::read_to_string(path).ok()) - .filter_map(|text| crate::ui::color_select::book::parse(&text).ok()) - .collect(); - self.color_books = std::sync::Arc::new(books); + match books.iter_mut().find(|b| b.name == loaded.name) { + Some(same) => *same = loaded, + None => books.push(loaded), + } } + self.color_books = std::sync::Arc::new(books); } /// Colour the selected objects (or set the current colour when nothing diff --git a/src/ui/color_select/book.rs b/src/ui/color_select/book.rs index f06cf8e3e..59bbdba88 100644 --- a/src/ui/color_select/book.rs +++ b/src/ui/color_select/book.rs @@ -57,6 +57,17 @@ impl ColorBook { } } +/// The colour books that ship with the program; their licences are in the +/// files' header comments. +pub fn builtin() -> impl Iterator { + [ + include_str!("../../../assets/color_books/ral-classic.acb"), + include_str!("../../../assets/color_books/css-colors.acb"), + ] + .into_iter() + .filter_map(|text| parse(text).ok()) +} + /// Read an `.acb` file's text. pub fn parse(text: &str) -> Result { let doc = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) From ecdead20c72ec609ba996efa105cbaa875d4ee19 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:57:53 +0000 Subject: [PATCH 17/32] Select Color: CMYK sliders, field below the sliders, Document first - Editor modes RGB, HSB and CMYK; the saturation/brightness field and hue strip now sit below the sliders in every mode instead of being a mode. - The panel opens on the Document tab. - Built-in colour books: RAL CLASSIC and SIKKENS 4041 (Open Colour Systems Collection 2.0, CC BY-ND 4.0); CSS COLORS removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- assets/color_books/README.md | 12 +- assets/color_books/css-colors.acb | 202 --- assets/color_books/sikkens-4041.acb | 2101 +++++++++++++++++++++++++++ src/ui/color_select/book.rs | 2 +- src/ui/color_select/editor.rs | 161 +- src/ui/color_select/math.rs | 18 + src/ui/color_select/picker.rs | 32 +- 7 files changed, 2205 insertions(+), 323 deletions(-) delete mode 100644 assets/color_books/css-colors.acb create mode 100644 assets/color_books/sikkens-4041.acb diff --git a/assets/color_books/README.md b/assets/color_books/README.md index d20dbd5f6..5b4198ea6 100644 --- a/assets/color_books/README.md +++ b/assets/color_books/README.md @@ -3,11 +3,11 @@ AutoCAD `.acb` colour books built into the Select Color panel. Each file carries its source and licence in its header comment. -| File | Book | Licence | +| File | Book | Source and licence | |---|---|---| -| `ral-classic.acb` | RAL CLASSIC, 216 approximate sRGB values | CC BY-SA 4.0, adapted from Wikipedia, "List of RAL colours" | -| `css-colors.acb` | CSS COLORS, the 148 CSS named colours | Values from the W3C CSS Color Module Level 4 | +| `ral-classic.acb` | RAL CLASSIC, 216 approximate sRGB values | Adapted from Wikipedia, "List of RAL colours"; CC BY-SA 4.0 | +| `sikkens-4041.acb` | SIKKENS 4041, 1,624 colours | Open Colour Systems Collection 2.0, © 2015 dtp studio oldenburg (freieFarbe e.V.); CC BY-ND 4.0. Converted from the GIMP palette: same names, values and order, only the file format changed | -RAL is a registered trademark of RAL gGmbH. The RAL values are approximate -screen representations, not colour-binding references, and are not -affiliated with, endorsed by or licensed by RAL gGmbH. +Values are approximate screen representations, not colour-binding +references. RAL is a registered trademark of RAL gGmbH and Sikkens of +AkzoNobel; these files are not affiliated with or endorsed by either. diff --git a/assets/color_books/css-colors.acb b/assets/color_books/css-colors.acb deleted file mode 100644 index 0868c4b83..000000000 --- a/assets/color_books/css-colors.acb +++ /dev/null @@ -1,202 +0,0 @@ - - - - CSS COLORS - 1 - 0 - - mistyrose255228225 - red25500 - salmon250128114 - lightcoral240128128 - indianred2059292 - rosybrown188143143 - firebrick1783434 - - - brown1654242 - darkred13900 - maroon12800 - coral25512780 - lightsalmon255160122 - orangered255690 - tomato2559971 - - - darksalmon233150122 - sienna1608245 - bisque255228196 - blanchedalmond255235205 - darkorange2551400 - navajowhite255222173 - papayawhip255239213 - - - peachpuff255218185 - antiquewhite250235215 - sandybrown24416496 - burlywood222184135 - chocolate21010530 - tan210180140 - peru20513363 - - - saddlebrown1396919 - cornsilk255248220 - gold2552150 - moccasin255228181 - orange2551650 - wheat245222179 - goldenrod21816532 - - - darkgoldenrod18413411 - lemonchiffon255250205 - lightyellow255255224 - yellow2552550 - lightgoldenrodyellow250250210 - beige245245220 - khaki240230140 - - - palegoldenrod238232170 - darkkhaki189183107 - olive1281280 - yellowgreen15420550 - olivedrab10714235 - darkolivegreen8510747 - chartreuse1272550 - - - greenyellow17325547 - lawngreen1242520 - lime02550 - palegreen152251152 - lightgreen144238144 - limegreen5020550 - darkseagreen143188143 - - - forestgreen3413934 - green01280 - darkgreen01000 - springgreen0255127 - mediumspringgreen0250154 - mediumseagreen60179113 - seagreen4613987 - - - aquamarine127255212 - mediumaquamarine102205170 - aqua0255255 - cyan0255255 - lightcyan224255255 - paleturquoise175238238 - powderblue176224230 - - - turquoise64224208 - darkturquoise0206209 - mediumturquoise72209204 - lightseagreen32178170 - cadetblue95158160 - darkcyan0139139 - teal0128128 - - - darkslategray477979 - darkslategrey477979 - deepskyblue0191255 - skyblue135206235 - lightblue173216230 - dodgerblue30144255 - lightskyblue135206250 - - - lightsteelblue176196222 - steelblue70130180 - lightslategray119136153 - lightslategrey119136153 - slategray112128144 - slategrey112128144 - cornflowerblue100149237 - - - royalblue65105225 - blue00255 - mediumblue00205 - darkblue00139 - navy00128 - midnightblue2525112 - mediumslateblue123104238 - - - mediumpurple147112219 - slateblue10690205 - darkslateblue7261139 - blueviolet13843226 - rebeccapurple10251153 - indigo750130 - darkviolet1480211 - - - mediumorchid18685211 - darkorchid15350204 - fuchsia2550255 - magenta2550255 - violet238130238 - plum221160221 - orchid218112214 - - - thistle216191216 - darkmagenta1390139 - purple1280128 - mediumvioletred19921133 - deeppink25520147 - hotpink255105180 - lightpink255182193 - - - pink255192203 - crimson2202060 - palevioletred219112147 - aliceblue240248255 - azure240255255 - floralwhite255250240 - ghostwhite248248255 - - - honeydew240255240 - ivory255255240 - lavenderblush255240245 - mintcream245255250 - seashell255245238 - snow255250250 - white255255255 - - - oldlace253245230 - lavender230230250 - linen250240230 - whitesmoke245245245 - gainsboro220220220 - lightgray211211211 - lightgrey211211211 - - - silver192192192 - darkgray169169169 - darkgrey169169169 - gray128128128 - grey128128128 - dimgray105105105 - dimgrey105105105 - - - black000 - - diff --git a/assets/color_books/sikkens-4041.acb b/assets/color_books/sikkens-4041.acb new file mode 100644 index 000000000..d1aba92aa --- /dev/null +++ b/assets/color_books/sikkens-4041.acb @@ -0,0 +1,2101 @@ + + + + SIKKENS 4041 + 1 + 0 + + Sikkens ON.00.90241239236 + Sikkens ON.00.81221221218 + Sikkens ON.00.69192191189 + Sikkens ON.00.60165163162 + Sikkens ON.00.50135135134 + Sikkens ON.00.40108107106 + Sikkens ON.00.31888686 + + + Sikkens ON.00.21686665 + Sikkens ON.00.86235234230 + Sikkens ON.00.76207206205 + Sikkens ON.00.64179177175 + Sikkens ON.00.55150150148 + Sikkens ON.00.45125124124 + Sikkens ON.00.3610099100 + + + Sikkens ON.00.26777676 + Sikkens ON.00.15575657 + Sikkens ON.00.83228229224 + Sikkens ON.00.78214214211 + Sikkens ON.00.63176174170 + Sikkens ON.00.58160159160 + Sikkens ON.00.53146146146 + + + Sikkens ON.00.33949493 + Sikkens ON.01.26808182 + Sikkens ON.00.10454443 + Sikkens AN.02.82231221221 + Sikkens AN.01.80223216214 + Sikkens AN.02.80230218217 + Sikkens AN.02.77222208209 + + + Sikkens AN.02.65184174174 + Sikkens AN.02.55157148149 + Sikkens AN.02.3510599100 + Sikkens AN.02.25827879 + Sikkens A0.05.85247223224 + Sikkens A0.05.75221197201 + Sikkens A0.05.65194173176 + + + Sikkens A0.05.55163143148 + Sikkens A0.05.45134119122 + Sikkens A0.05.3511397101 + Sikkens A0.05.25867476 + Sikkens A0.05.15665858 + Sikkens A0.07.77237204212 + Sikkens A0.10.75245195206 + + + Sikkens A0.10.70227181194 + Sikkens A0.10.60194155166 + Sikkens A0.10.50163129139 + Sikkens A0.10.40133105113 + Sikkens A0.10.301057886 + Sikkens A0.10.20826266 + Sikkens A0.15.65225162177 + + + Sikkens A0.20.60215140162 + Sikkens A0.20.50190123143 + Sikkens A0.20.4015495111 + Sikkens A0.20.301257592 + Sikkens A0.20.20965768 + Sikkens A0.20.12724353 + Sikkens A6.25.05694548 + + + Sikkens A8.20.60218143151 + Sikkens A6.30.50201104122 + Sikkens A0.30.50204104138 + Sikkens A6.35.4018279107 + Sikkens A0.30.4016883111 + Sikkens A0.30.301376693 + Sikkens A0.41.281494384 + + + Sikkens A0.30.201094669 + Sikkens B3.19.67239158159 + Sikkens B2.30.60227126132 + Sikkens B3.37.51222100111 + Sikkens B2.40.5021793105 + Sikkens B0.39.401876990 + Sikkens B0.39.301515674 + + + Sikkens B0.35.251265163 + Sikkens B0.25.251186472 + Sikkens B3.31.54228116124 + Sikkens B6.40.5021795100 + Sikkens B6.40.401837481 + Sikkens B6.50.401925874 + Sikkens B2.40.401856687 + + + Sikkens B3.49.331714767 + Sikkens B8.50.201243541 + Sikkens B6.45.151053946 + Sikkens B3.14.73247183182 + Sikkens B3.25.60233139144 + Sikkens B2.30.50210104116 + Sikkens B2.30.401678594 + + + Sikkens B0.35.351626884 + Sikkens B2.30.301366877 + Sikkens B2.24.251176470 + Sikkens B2.30.201064653 + Sikkens B5.08.80248204200 + Sikkens B2.20.70245166168 + Sikkens B2.20.60215144146 + + + Sikkens B2.20.50193120125 + Sikkens B2.20.401509599 + Sikkens B2.20.301186773 + Sikkens B2.20.20975961 + Sikkens B1.16.12694547 + Sikkens B7.06.83251216211 + Sikkens B6.20.70243170167 + + + Sikkens B6.30.60232131128 + Sikkens B6.30.50202107110 + Sikkens B6.35.401698083 + Sikkens B6.30.401668485 + Sikkens B6.30.301356870 + Sikkens B6.28.19995046 + Sikkens B3.07.78240203202 + + + Sikkens B6.10.70229184180 + Sikkens B6.15.65219159155 + Sikkens B6.20.60216143140 + Sikkens B6.20.50184120116 + Sikkens B6.20.401489796 + Sikkens B6.20.301206869 + Sikkens B7.28.191004750 + + + Sikkens B2.10.80248206205 + Sikkens B2.10.70221176175 + Sikkens B2.10.60197156157 + Sikkens B2.10.50162125126 + Sikkens B2.10.40134103106 + Sikkens B2.10.301088384 + Sikkens B2.10.25946870 + + + Sikkens B2.10.20846263 + Sikkens B6.05.85249227223 + Sikkens B2.05.75229202203 + Sikkens B2.05.70208185184 + Sikkens B2.05.65199175177 + Sikkens B2.05.55167150148 + Sikkens B1.05.45139122122 + + + Sikkens B3.05.40124110109 + Sikkens B1.05.351139899 + Sikkens CN.02.88249243236 + Sikkens CN.03.86248232226 + Sikkens CN.02.77220208203 + Sikkens CN.02.67193184181 + Sikkens CN.02.57162155153 + + + Sikkens CN.02.47136128127 + Sikkens CN.02.37111105104 + Sikkens CN.02.27858079 + Sikkens C0.04.83247224220 + Sikkens C0.04.78232212208 + Sikkens C0.05.75220199196 + Sikkens C0.05.65194173171 + + + Sikkens C0.05.55166146143 + Sikkens C0.05.45136121119 + Sikkens C0.05.35116101100 + Sikkens C0.05.25867573 + Sikkens C0.07.82250218211 + Sikkens C0.07.77236205200 + Sikkens C0.10.70223182176 + + + Sikkens C0.10.60195158153 + Sikkens C0.10.50161130128 + Sikkens C0.10.40137108104 + Sikkens C0.10.301088683 + Sikkens C0.10.20816564 + Sikkens C0.10.80248205197 + Sikkens C0.15.75247187180 + + + Sikkens C0.15.65223165159 + Sikkens C0.15.55188135131 + Sikkens C0.20.50185122115 + Sikkens C0.20.4015210094 + Sikkens C0.20.301217470 + Sikkens C0.30.201004845 + Sikkens C0.08.80249211202 + + + Sikkens C4.20.70238171155 + Sikkens C4.20.60205146133 + Sikkens C4.20.50177123113 + Sikkens C4.30.50193116100 + Sikkens C0.30.401578976 + Sikkens C0.30.301327364 + Sikkens C0.40.201154841 + + + Sikkens C1.10.79249201188 + Sikkens C0.20.70248173160 + Sikkens C0.20.60212144137 + Sikkens C0.25.55201124119 + Sikkens C0.25.4517110297 + Sikkens C0.30.401678579 + Sikkens C0.30.301255955 + + + Sikkens C4.35.15994642 + Sikkens C0.17.72246173161 + Sikkens C0.25.65238151143 + Sikkens C0.30.60235132126 + Sikkens C0.30.50207115107 + Sikkens B8.35.451949690 + Sikkens C0.40.401858482 + + + Sikkens C0.40.301466362 + Sikkens C0.40.201103937 + Sikkens C3.20.73255180163 + Sikkens C0.28.58226132121 + Sikkens C0.40.5021210090 + Sikkens C0.50.401905958 + Sikkens C0.50.301554750 + + + Sikkens C2.60.251413738 + Sikkens C2.50.251253936 + Sikkens C0.45.201134040 + Sikkens C4.30.60228136117 + Sikkens C4.40.5020710180 + Sikkens C4.40.401788367 + Sikkens C4.50.401867057 + + + Sikkens C3.57.311665048 + Sikkens C4.40.301516150 + Sikkens C0.60.301683344 + Sikkens C0.48.201273340 + Sikkens C2.24.65237145131 + Sikkens C2.38.5021610792 + Sikkens C2.53.401956762 + + + Sikkens C4.60.402025649 + Sikkens C6.65.351764538 + Sikkens C4.75.301813334 + Sikkens C4.60.301664338 + Sikkens C4.50.301515346 + Sikkens C4.32.60235136117 + Sikkens C5.43.5824612498 + + + Sikkens C5.43.5423111597 + Sikkens C4.46.502219880 + Sikkens C4.50.502218978 + Sikkens C4.55.401965836 + Sikkens C4.49.331646958 + Sikkens C4.55.301494739 + Sikkens C8.34.63245146112 + + + Sikkens C8.45.5523511683 + Sikkens C8.50.5021910275 + Sikkens C6.49.482078761 + Sikkens C8.50.401848154 + Sikkens C8.51.391767849 + Sikkens C8.60.301575643 + Sikkens C8.50.301436649 + + + Sikkens C7.26.67247166141 + Sikkens C8.30.60224141112 + Sikkens C8.40.6023912896 + Sikkens C8.40.5021110976 + Sikkens C8.40.401739170 + Sikkens C8.40.301346546 + Sikkens C7.34.241156757 + + + Sikkens C8.40.201095848 + Sikkens C7.22.72249175152 + Sikkens C8.25.65234156132 + Sikkens C8.25.55200133111 + Sikkens C8.30.5019111592 + Sikkens C8.25.4517111293 + Sikkens C8.30.401629777 + + + Sikkens C8.30.301317862 + Sikkens C8.30.201005745 + Sikkens C8.15.75243191172 + Sikkens C8.19.74255189165 + Sikkens C8.20.70244179157 + Sikkens C8.20.60219150130 + Sikkens C8.20.4014610185 + + + Sikkens C8.20.301117157 + Sikkens C8.20.20916155 + Sikkens C4.05.20746461 + Sikkens C8.07.82247217208 + Sikkens C8.10.80253213200 + Sikkens C8.10.70219185172 + Sikkens C8.15.65216166152 + + + Sikkens C8.20.50174126109 + Sikkens C4.20.401449887 + Sikkens C4.20.301167768 + Sikkens C6.22.231006557 + Sikkens CN.01.90250241235 + Sikkens C8.04.83242226219 + Sikkens C8.05.75220199191 + + + Sikkens C8.10.60190159149 + Sikkens C8.15.55184144131 + Sikkens C8.16.48163122108 + Sikkens C9.21.311198169 + Sikkens C8.25.18936458 + Sikkens C8.03.78222209204 + Sikkens C4.05.65195177171 + + + Sikkens C8.05.65191173168 + Sikkens C8.05.55164147142 + Sikkens C8.10.50158133124 + Sikkens C8.10.40133108101 + Sikkens C8.10.30998074 + Sikkens C8.10.20846963 + Sikkens D2.03.86249234223 + + + Sikkens D2.05.80244219208 + Sikkens D2.10.70222184168 + Sikkens D2.10.60192162147 + Sikkens D2.10.50155131121 + Sikkens D2.10.40129108101 + Sikkens D2.10.301038780 + Sikkens D2.10.25876962 + + + Sikkens D2.10.80247211196 + Sikkens D2.15.75243194170 + Sikkens D2.15.65210166146 + Sikkens D2.15.55180142126 + Sikkens D3.18.4314711396 + Sikkens D2.20.301147863 + Sikkens D2.15.25906758 + + + Sikkens D2.15.15826460 + Sikkens D2.15.80246205184 + Sikkens D2.20.70234178151 + Sikkens D2.20.60201147121 + Sikkens D2.20.50173125103 + Sikkens D2.20.4014310082 + Sikkens D2.25.301217857 + + + Sikkens D2.30.301257858 + Sikkens D2.35.251247152 + Sikkens D2.30.70241160123 + Sikkens D2.30.60221144106 + Sikkens D2.35.5521212990 + Sikkens D2.30.5018111890 + Sikkens D2.35.4517210574 + + + Sikkens D2.30.401599768 + Sikkens D2.40.401639163 + Sikkens D2.40.301357855 + Sikkens D4.23.71237168131 + Sikkens D6.35.70244159107 + Sikkens D2.40.6023613690 + Sikkens D2.50.5523912269 + + + Sikkens D2.40.5019711276 + Sikkens D2.50.5020510261 + Sikkens D2.50.301437143 + Sikkens D4.30.201016246 + Sikkens D9.25.74251187142 + Sikkens D7.41.6022914690 + Sikkens D6.47.6124214079 + + + Sikkens D6.58.5824412248 + Sikkens D5.63.5323510836 + Sikkens D3.71.482299037 + Sikkens D3.65.432078535 + Sikkens D1.58.401947941 + Sikkens D9.40.6825016599 + Sikkens D6.50.6023813565 + + + Sikkens D8.65.5023010930 + Sikkens D6.60.5021010337 + Sikkens D6.50.5020210955 + Sikkens D6.60.401768835 + Sikkens D6.50.401679046 + Sikkens D6.50.301427543 + Sikkens D8.37.65239160102 + + + Sikkens D6.40.6022814084 + Sikkens D6.35.5520313592 + Sikkens D6.40.5019711466 + Sikkens D6.40.401589151 + Sikkens D6.40.301277648 + Sikkens D6.30.301258360 + Sikkens D6.30.20946348 + + + Sikkens D6.25.70247181141 + Sikkens D6.30.70245169124 + Sikkens D6.30.60219145103 + Sikkens D6.25.65223161125 + Sikkens D6.30.5017912388 + Sikkens D6.35.4517211275 + Sikkens D6.30.401489662 + + + Sikkens D6.25.301178261 + Sikkens D6.20.80251195162 + Sikkens D6.20.70228173142 + Sikkens D6.20.60202153125 + Sikkens D6.25.55192137108 + Sikkens D6.20.50173128102 + Sikkens D6.20.4013810079 + + + Sikkens D6.20.301087759 + Sikkens D6.20.20866451 + Sikkens D7.10.82251217198 + Sikkens D6.10.80245214196 + Sikkens D6.15.75239197175 + Sikkens D6.15.65210170147 + Sikkens D6.15.55175141121 + + + Sikkens D6.10.4012810997 + Sikkens D6.15.25916956 + Sikkens D6.10.20837163 + Sikkens D6.05.65242223212 + Sikkens D6.07.82242218203 + Sikkens D6.07.77229204189 + Sikkens D6.10.70213182164 + + + Sikkens D6.10.60185156139 + Sikkens D6.10.50155134121 + Sikkens D6.10.30998374 + Sikkens E0.05.25797064 + Sikkens DN.02.82233224218 + Sikkens D6.04.78229217207 + Sikkens D6.05.75223205195 + + + Sikkens D6.05.65196180171 + Sikkens D6.05.55165151144 + Sikkens D6.05.45138127121 + Sikkens D6.05.3511210398 + Sikkens D6.05.25867773 + Sikkens E0.03.88247236226 + Sikkens E0.03.84243231221 + + + Sikkens E0.05.75217201188 + Sikkens E0.10.70215187168 + Sikkens E0.10.60182158143 + Sikkens E0.10.50152131116 + Sikkens E0.05.45136125117 + Sikkens E0.10.30998778 + Sikkens E0.10.80243215196 + + + Sikkens E1.14.79247209179 + Sikkens E0.15.75239197167 + Sikkens E0.15.65205171146 + Sikkens E0.15.55175144121 + Sikkens E0.15.45145119100 + Sikkens E0.10.4012710995 + Sikkens E0.15.351179476 + + + Sikkens E0.20.80252202162 + Sikkens E0.20.70231183145 + Sikkens E0.25.65220168127 + Sikkens E0.20.60193152120 + Sikkens E0.20.50167129103 + Sikkens E0.25.4516111988 + Sikkens E0.20.4013510277 + + + Sikkens E0.20.301087654 + Sikkens E0.25.75250189141 + Sikkens E0.30.70245179127 + Sikkens E1.21.69225178139 + Sikkens E0.30.60207152109 + Sikkens E0.25.55187141105 + Sikkens E0.30.5017112182 + + + Sikkens E0.30.4014610165 + Sikkens E0.25.351289467 + Sikkens E0-35.65230163108 + Sikkens E0.40.6021514386 + Sikkens E0.35.5519813787 + Sikkens E0.45.5521112964 + Sikkens E0.50.5019811756 + + + Sikkens E0.40.5018812070 + Sikkens E0.40.4015910253 + Sikkens E0.45.301398747 + Sikkens E0.40.7024816396 + Sikkens E0.50.6525015065 + Sikkens E0.50.6023413862 + Sikkens E0.60.5020311540 + + + Sikkens E0.60.401679334 + Sikkens E0.50.401629546 + Sikkens E0.48.301297838 + Sikkens E0.30.20986848 + Sikkens E4.50.7025016674 + Sikkens E4.50.6022214458 + Sikkens E4.60.6023514132 + + + Sikkens E4.60.5019511730 + Sikkens E4.50.5018912248 + Sikkens E4.50.401579738 + Sikkens E0.40.301227842 + Sikkens E0.30.301208151 + Sikkens E5.27.77255201142 + Sikkens E4.30.75249189126 + + + Sikkens E4.40.7025117798 + Sikkens E4.40.6021414981 + Sikkens E4.40.5018612867 + Sikkens E4.40.4014510053 + Sikkens E4.40.301198042 + Sikkens E5.35.251007247 + Sikkens E6.19.80255213165 + + + Sikkens E4.30.70233176118 + Sikkens E4.30.60204156107 + Sikkens E4.30.5016612581 + Sikkens E1.22.4716713099 + Sikkens E4.30.4013910263 + Sikkens E4.30.301148762 + Sikkens E4.30.20866238 + + + Sikkens E4.20.80252210169 + Sikkens E4.25.75245188133 + Sikkens E4.25.65214166122 + Sikkens E1.26.54193146104 + Sikkens E4.25.55184142102 + Sikkens E4.25.351229466 + Sikkens E4.25.301148660 + + + Sikkens E4.15.25917560 + Sikkens E4.10.85251225195 + Sikkens E3.20.77247200159 + Sikkens E4.20.75243197153 + Sikkens E4.20.70221183146 + Sikkens E4.20.60196158127 + Sikkens E4.20.50167135106 + + + Sikkens E4.20.4013711082 + Sikkens E4.20.30997553 + Sikkens E5.08.84248227205 + Sikkens E4.07.82240220199 + Sikkens E4.10.80245220193 + Sikkens E4.15.75233199166 + Sikkens E4.15.65205171141 + + + Sikkens E4.15.55175147122 + Sikkens E4.15.4513911694 + Sikkens E4.15.351179577 + Sikkens E4.03.80222214207 + Sikkens E4.05.75216204191 + Sikkens E4.07.77223206189 + Sikkens E4.10.70209189171 + + + Sikkens E4.10.60179160142 + Sikkens E4.10.50153134117 + Sikkens E4.10.4012010796 + Sikkens E4.10.30928069 + Sikkens E4.05.85246231218 + Sikkens E6.07.84246228210 + Sikkens E1.09.78234210190 + + + Sikkens E3.09.72214194175 + Sikkens E0.11.67204179159 + Sikkens E0.09.53161142127 + Sikkens E8.10.4012311096 + Sikkens E8.10.301019078 + Sikkens EN.02.88246238230 + Sikkens EN.02.87246238230 + + + Sikkens EN.02.85238232225 + Sikkens E5.05.77214201185 + Sikkens E4.05.65186174163 + Sikkens E4.05.55158148140 + Sikkens E4.05.3510810094 + Sikkens E4.05.20736864 + Sikkens EN.02.90249243236 + + + Sikkens E4.04.83238226213 + Sikkens E8.05.80231219204 + Sikkens E8.07.77225210192 + Sikkens E8.05.70204194183 + Sikkens E8.03.60172165159 + Sikkens E4.05.45131122114 + Sikkens E4.05.25837772 + + + Sikkens E8.10.85251229205 + Sikkens E8.10.80235218196 + Sikkens E8.10.75221201176 + Sikkens E8.10.70211190167 + Sikkens E8.10.60176160142 + Sikkens E8.10.50149134118 + Sikkens E8.15.351109576 + + + Sikkens E8.10.25948577 + Sikkens E8.15.85251225192 + Sikkens E8.15.80243214180 + Sikkens E6.18.76241204163 + Sikkens E8.15.75223196164 + Sikkens E8.15.65201176146 + Sikkens E8.15.55171149123 + + + Sikkens E8.15.4513811998 + Sikkens E8.20.4013411388 + Sikkens E8.20.80247211165 + Sikkens E8.20.75240204155 + Sikkens E8.20.70217184140 + Sikkens E8.25.65209170120 + Sikkens E8.20.60186155119 + + + Sikkens E8.20.50160134104 + Sikkens E8.25.4514812087 + Sikkens E8.20.301048665 + Sikkens E8.25.80255210147 + Sikkens E8.25.75239195140 + Sikkens E8.30.70231183118 + Sikkens E8.35.65217168101 + + + Sikkens E8.30.60196156106 + Sikkens E8.25.55180146105 + Sikkens E5.21.49166135101 + Sikkens E8.30.4013510665 + Sikkens E8.35.75252190115 + Sikkens E8.40.7024017694 + Sikkens E8.40.6021215478 + + + Sikkens E8.35.5519214788 + Sikkens E8.35.4515711765 + Sikkens E8.40.4014310452 + Sikkens E8.25.351189464 + Sikkens E8.30.301128756 + Sikkens E9.44.7325218888 + Sikkens E8.50.7024417663 + + + Sikkens E8.55.7025117050 + Sikkens E8.60.6022814723 + Sikkens E8.50.6021415458 + Sikkens E8.50.5018613149 + Sikkens E8.40.5017813065 + Sikkens E8.55.4517911843 + Sikkens E9.29.79255207135 + + + Sikkens E7.36.76255195113 + Sikkens F0.40.7023918393 + Sikkens E8.49.7325518471 + Sikkens E6.58.6524016139 + Sikkens E7.72.582211390 + Sikkens E6.63.5320413024 + Sikkens E6.63.4718211523 + + + Sikkens F3.40.77254204100 + Sikkens F2.45.7525519271 + Sikkens F2.55.7525218739 + Sikkens F2.50.7023817955 + Sikkens F2.70.652401670 + Sikkens F2.60.7023817640 + Sikkens F2.60.6021115116 + + + Sikkens F2.50.5016912744 + Sikkens F3.33.80255214125 + Sikkens F2.35.80254205113 + Sikkens F2.40.7023318488 + Sikkens F2.50.6522216853 + Sikkens F2.50.6020715750 + Sikkens F2.50.5519114544 + + + Sikkens F2.50.4014610631 + Sikkens F2.40.301158434 + Sikkens F5.20.80242218164 + Sikkens F2.30.80249211140 + Sikkens F2.35.75243197115 + Sikkens F2.35.6521116999 + Sikkens F2.40.6019915780 + + + Sikkens F2.40.5016913361 + Sikkens F2.40.4013810647 + Sikkens F2.30.301058651 + Sikkens F2.25.75232199141 + Sikkens F2.30.70221185118 + Sikkens F2.25.65205175121 + Sikkens F2.30.60194159100 + + + Sikkens F2.25.55180150103 + Sikkens F2.30.5016513787 + Sikkens F2.25.4514311879 + Sikkens F2.30.4013611168 + Sikkens F2.15.85251223185 + Sikkens F0.20.80244211167 + Sikkens F0.24.80254214154 + + + Sikkens F0.25.75240205149 + Sikkens F0.30.70229186122 + Sikkens F0.25.70214178128 + Sikkens F1.22.52161137100 + Sikkens F2.20.4012810979 + Sikkens F0.10.85247228203 + Sikkens F0.17.80242214170 + + + Sikkens F2.15.80239215175 + Sikkens F2.20.80242212163 + Sikkens F2.20.70217189145 + Sikkens F2.20.60187162123 + Sikkens F2.20.50154133100 + Sikkens F2.20.30998257 + Sikkens F2.08.86249233210 + + + Sikkens F2.10.80234216190 + Sikkens F0.15.75222199166 + Sikkens F2.15.75229204166 + Sikkens F2.15.65193173141 + Sikkens F2.15.55164146118 + Sikkens F2.15.4513712196 + Sikkens F2.15.351119878 + + + Sikkens F2.07.88250238219 + Sikkens F2.06.84239228211 + Sikkens F2.06.74214206190 + Sikkens F2.10.70208192170 + Sikkens F2.10.60176163143 + Sikkens F2.10.50150138121 + Sikkens F2.10.4011911095 + + + Sikkens F2.10.30948470 + Sikkens F2.05.87248239222 + Sikkens F2.05.85243232216 + Sikkens F2.03.84235229221 + Sikkens F2.05.75211203191 + Sikkens F2.05.65184176165 + Sikkens F2.05.55160154145 + + + Sikkens F2.05.45129124116 + Sikkens F2.05.351059890 + Sikkens FN.02.88241235224 + Sikkens FN.02.82227221212 + Sikkens FN.02.77211207199 + Sikkens FN.02.67179176168 + Sikkens FN.02.57159155148 + + + Sikkens FN.02.47132129124 + Sikkens FN.02.3710710499 + Sikkens FN.02.27848176 + Sikkens F6.03.87243236225 + Sikkens F6.03.72203198189 + Sikkens F6.05.70200191178 + Sikkens F6.05.60168161149 + + + Sikkens F6.05.50144139128 + Sikkens F6.05.40117111103 + Sikkens F6.05.30948981 + Sikkens F6.03.20686562 + Sikkens FN.02.85233230222 + Sikkens F6.05.80227222204 + Sikkens F6.10.80229215190 + + + Sikkens F6.10.70200186162 + Sikkens F6.10.60174162139 + Sikkens F6.10.50151141123 + Sikkens F6.10.4012011196 + Sikkens F6.10.30938776 + Sikkens F2.03.88245239227 + Sikkens F6.07.77218206187 + + + Sikkens F6.15.75218200164 + Sikkens F6.15.65190175144 + Sikkens F6.15.55166152124 + Sikkens F8.16.48143132105 + Sikkens F6.15.45139127102 + Sikkens F6.15.3511310282 + Sikkens F6.07.82237225203 + + + Sikkens F6.15.80235216175 + Sikkens F6.20.80238213164 + Sikkens F6.20.70210190145 + Sikkens F6.20.60177158116 + Sikkens F6.20.50151134100 + Sikkens F6.20.4012611179 + Sikkens F6.20.30938257 + + + Sikkens F6.05.85242236221 + Sikkens F6.15.85248228187 + Sikkens F6.25.80244216148 + Sikkens F6.25.75231206149 + Sikkens F6.25.70212189129 + Sikkens F6.25.65201176119 + Sikkens F6.30.5015413280 + + + Sikkens F6.30.301048956 + Sikkens F6.09.86250236209 + Sikkens F6.20.83249227172 + Sikkens F6.26.81252219148 + Sikkens F6.35.75232197112 + Sikkens F6.30.70222188106 + Sikkens F6.35.65203174103 + + + Sikkens F6.30.60186162104 + Sikkens F6.30.4013311677 + Sikkens F6.25.85252223158 + Sikkens F6.30.80251214136 + Sikkens F6.40.80251207103 + Sikkens F6.40.7022318789 + Sikkens F6.40.6019416175 + + + Sikkens F6.40.5015612956 + Sikkens F6.40.4013411156 + Sikkens F6.40.301028742 + Sikkens F8.15.80233216173 + Sikkens F8.20.80235215166 + Sikkens F8.20.75224208159 + Sikkens F8.30.70214190120 + + + Sikkens F8.40.70219191100 + Sikkens F8.40.6019116477 + Sikkens F8.45.5518315558 + Sikkens F8.41.4614912862 + Sikkens F8.25.81239216151 + Sikkens F8.30.80244214134 + Sikkens F6.45.7524119574 + + + Sikkens F6.55.7524719854 + Sikkens F6.50.7022618752 + Sikkens F6.50.6019016053 + Sikkens F6.50.5016513141 + Sikkens F6.50.4013711040 + Sikkens G0.31.84247222130 + Sikkens F8.40.80253218105 + + + Sikkens F8.50.8025321068 + Sikkens F8.60.8025521236 + Sikkens F6.60.7022718318 + Sikkens F6.70.702351840 + Sikkens F6.60.6019815833 + Sikkens F6.55.5417914435 + Sikkens G0.35.85246226131 + + + Sikkens G0.40.80241216109 + Sikkens G0.45.8025122097 + Sikkens G0.50.8024721972 + Sikkens G0.55.8024821952 + Sikkens G0.60.702231902 + Sikkens G0.55.7021918937 + Sikkens G0.60.6018715927 + + + Sikkens G3.34.84249231128 + Sikkens G2.51.8124622474 + Sikkens G1.57.7924121543 + Sikkens G0.60.7523220613 + Sikkens G0.70.762422120 + Sikkens G0.50.7021718761 + Sikkens G0.50.6018716147 + + + Sikkens G0.50.5016014042 + Sikkens G5.27.85246237154 + Sikkens G4.34.84245234132 + Sikkens G3.46.8324622885 + Sikkens G0.35.75225204110 + Sikkens G0.40.7020518492 + Sikkens G0.40.6018015975 + + + Sikkens G0.40.5015013661 + Sikkens G0.40.4012511148 + Sikkens G0.25.85250229158 + Sikkens G0.30.80239216134 + Sikkens G0.25.75225206144 + Sikkens G0.30.70212190117 + Sikkens G0.25.65198179120 + + + Sikkens G0.30.60180162100 + Sikkens G0.30.5015113783 + Sikkens G0.30.30988952 + Sikkens G0.15.85249235188 + Sikkens G0.20.80237219163 + Sikkens G0.20.70207191140 + Sikkens G0.20.60180165122 + + + Sikkens G0.20.5014613598 + Sikkens G0.20.4012411482 + Sikkens G0.20.30958762 + Sikkens G0.20.20736750 + Sikkens GN.02.90246242230 + Sikkens G0.08.84235224196 + Sikkens G0.10.80227214186 + + + Sikkens G0.10.75214203174 + Sikkens G0.15.75217201161 + Sikkens G0.15.65186174140 + Sikkens G0.10.50144137117 + Sikkens G0.10.30928773 + Sikkens G0.05.88245239219 + Sikkens G0.08.88248238211 + + + Sikkens G0.09.85242233204 + Sikkens G1.13.79227216179 + Sikkens G0.10.70202193166 + Sikkens G0.10.65187179157 + Sikkens G0.10.60172163142 + Sikkens G0.10.4011510994 + Sikkens GN.01.88246245234 + + + Sikkens G4.04.88245240223 + Sikkens G0.05.85235229210 + Sikkens G0.05.75212206190 + Sikkens G0.05.65182178165 + Sikkens G0.05.55157152141 + Sikkens G0.05.45128125116 + Sikkens G0.05.25747166 + + + Sikkens GN.00.88239239236 + Sikkens G4.03.88245242231 + Sikkens G0.03.86235232219 + Sikkens G0.04.84233228214 + Sikkens G0.05.80224217202 + Sikkens G0.05.70197191177 + Sikkens G4.05.55157154144 + + + Sikkens G0.05.3510310093 + Sikkens G4.04.84237233217 + Sikkens G4.08.83234229204 + Sikkens G4.05.81224220205 + Sikkens G4.07.80225217197 + Sikkens G4.05.77214209194 + Sikkens G4.10.70198190162 + + + Sikkens G4.10.60171166145 + Sikkens G8.10.4011411396 + Sikkens G4.08.88247243218 + Sikkens G4.13.85244235196 + Sikkens G4.15.85241232185 + Sikkens G4.10.80227223196 + Sikkens G4.15.75211204163 + + + Sikkens G4.20.70205198147 + Sikkens G4.15.65186179146 + Sikkens G4.20.5014113496 + Sikkens G8.06.90244241221 + Sikkens G8.06.85233230209 + Sikkens G8.10.85239234204 + Sikkens G8.10.80227224188 + + + Sikkens G8.10.70194192167 + Sikkens G8.10.60166165140 + Sikkens G8.10.50141138119 + Sikkens G8.10.30858369 + Sikkens G9.13.85237236192 + Sikkens G8.20.85236231173 + Sikkens G8.20.80222219162 + + + Sikkens G8.20.70196196146 + Sikkens G8.20.60170169124 + Sikkens G8.20.5013913799 + Sikkens G8.20.4011611683 + Sikkens G8.20.30908861 + Sikkens G7.18.87246242182 + Sikkens G8.20.90242241177 + + + Sikkens G8.30.85238240144 + Sikkens G8.30.80225227136 + Sikkens G8.30.70199195117 + Sikkens G8.30.60171168102 + Sikkens G8.30.5014414485 + Sikkens G8.30.30919153 + Sikkens G5.24.85246237159 + + + Sikkens G5.36.83242232119 + Sikkens G8.40.80225229111 + Sikkens G8.40.7019819784 + Sikkens G8.40.6016516673 + Sikkens G8.40.5014013962 + Sikkens G8.30.4011511564 + Sikkens G8.30.20696838 + + + Sikkens G3.26.85248236164 + Sikkens G4.30.80234224145 + Sikkens G4.30.70203190112 + Sikkens G4.40.7020719390 + Sikkens G4.40.6017516371 + Sikkens G4.30.6017616799 + Sikkens G4.30.5014914083 + + + Sikkens G4.25.3510910567 + Sikkens G4.40.80232218103 + Sikkens G4.50.8023021670 + Sikkens G4.50.7019919158 + Sikkens G4.60.7020019110 + Sikkens G4.60.601771640 + Sikkens G4.50.6017316145 + + + Sikkens G4.40.5014913959 + Sikkens G4.50.5015014040 + Sikkens G8.45.8021722173 + Sikkens G8.50.7018819250 + Sikkens G7.57.6618519042 + Sikkens G8.60.6017017021 + Sikkens G8.50.6016416852 + + + Sikkens G8.50.5014214036 + Sikkens G8.40.4011711953 + Sikkens G8.40.30919344 + Sikkens H1.39.83222236115 + Sikkens G9.56.7620421950 + Sikkens G8.60.7018919726 + Sikkens H2.55.6516418846 + + + Sikkens H2.60.6015117516 + Sikkens H2.50.6015617149 + Sikkens H2.50.5013214240 + Sikkens H2.45.4512013352 + Sikkens G9.27.84232236151 + Sikkens G9.35.84230236126 + Sikkens H2.40.80214224102 + + + Sikkens H2.45.7519721488 + Sikkens H2.50.7018020061 + Sikkens H2.40.7018720094 + Sikkens H2.40.6016216977 + Sikkens H2.40.5013814769 + Sikkens H2.15.85234240187 + Sikkens H2.30.80211223134 + + + Sikkens H3.24,78210220151 + Sikkens H2.20.70191196142 + Sikkens H2.30.70187195118 + Sikkens H2.30.60159170101 + Sikkens H2.30.5013314084 + Sikkens H2.35.30889248 + Sikkens H4.06.85234237216 + + + Sikkens H2.07.87239239213 + Sikkens H6.06.80220222198 + Sikkens H3.12.78211216180 + Sikkens H4.16.70187195153 + Sikkens H3.18.60162168127 + Sikkens H2.20.5013714099 + Sikkens H2.25.30868959 + + + Sikkens H2.08.90245244216 + Sikkens H2.04.88241241223 + Sikkens H2.08.80224222200 + Sikkens H2.10.80221221193 + Sikkens H2.10.70190192165 + Sikkens H2.10.60166169144 + Sikkens H3.10.43120122104 + + + Sikkens H2.12.25787965 + Sikkens H2.03.90246245229 + Sikkens HN.02.85233233222 + Sikkens HN.02.88240239231 + Sikkens H2.03.82224225214 + Sikkens H3.04.72197198185 + Sikkens H2.05.65178178166 + + + Sikkens H2.05.55150149136 + Sikkens H2.05.45131132123 + Sikkens J0.04.88239243226 + Sikkens JN.02.82222226217 + Sikkens J0.05.75201207188 + Sikkens J0.05.65176180165 + Sikkens J0.05.55149152139 + + + Sikkens J0.05.45124126116 + Sikkens J0.05.3510010294 + Sikkens J0.05.25767770 + Sikkens J0.05.85227230211 + Sikkens J0.07.82221228204 + Sikkens J0.07.77207214190 + Sikkens J0.10.70185193167 + + + Sikkens J0.10.60162168147 + Sikkens J0.10.50134140120 + Sikkens J0.10.4011011499 + Sikkens J0.10.30868975 + Sikkens J0.15.85221238189 + Sikkens J0.10.80209220189 + Sikkens J0.15.75197210166 + + + Sikkens J0.15.65170183145 + Sikkens J0.20.60154170123 + Sikkens J0.20.50123139100 + Sikkens J0.20.4010511683 + Sikkens J0.15.25778365 + Sikkens J0.20.80203225165 + Sikkens J0.20.70178196146 + + + Sikkens J0.30.70171197120 + Sikkens J0.30.60147170105 + Sikkens J0.30.5012714690 + Sikkens J0.30.409711769 + Sikkens J0.20.30818961 + Sikkens J0.20.20657254 + Sikkens H9.21.83214235173 + + + Sikkens J0.30.80198224139 + Sikkens J0.40.7015520198 + Sikkens J0.40.6013817482 + Sikkens J0.40.5011014261 + Sikkens J0.40.409612059 + Sikkens J0.30.30879962 + Sikkens J0.30.20627240 + + + Sikkens H7.22.83214235164 + Sikkens H7.38.79195229113 + Sikkens J0.48.7015520476 + Sikkens J0.50.6012717358 + Sikkens J0.50.5010814749 + Sikkens J0.60.5010614932 + Sikkens J0.50.409212041 + + + Sikkens J0.40.30749437 + Sikkens J1.21.83208235174 + Sikkens J6.26.76163213141 + Sikkens J8.41.70135211114 + Sikkens J6.39.63132189102 + Sikkens J6.45.5310416074 + Sikkens J6.51.437913457 + + + Sikkens J7.41.357411156 + Sikkens J4.44.25558340 + Sikkens J9.19.80191230177 + Sikkens K0.28.76164221149 + Sikkens J8.48.6411419686 + Sikkens K2.50.608418182 + Sikkens K2.50.506815368 + + + Sikkens K2.50.406113058 + Sikkens K2.60.404313547 + Sikkens K6.50.304510455 + Sikkens J6.11.83206229193 + Sikkens K0.26.76168221154 + Sikkens K2.40.70128208116 + Sikkens K2.40.60110178100 + + + Sikkens K2.40.508815384 + Sikkens K2.40.407312467 + Sikkens K2.40.30579852 + Sikkens K2.45.20477946 + Sikkens K0.19.78189227178 + Sikkens K5.21.77168221172 + Sikkens K6.34.67123200131 + + + Sikkens K5.44.6492197107 + Sikkens K7.48.566117796 + Sikkens K7.58.44814269 + Sikkens K7.60.30010545 + Sikkens K4.54.23418744 + Sikkens K2.10.80203224197 + Sikkens K2.21.72162208156 + + + Sikkens K2.30.70144205135 + Sikkens K2.30.60120174114 + Sikkens K2.30.5010515098 + Sikkens K2.30.407911975 + Sikkens K2.30.30669863 + Sikkens K2.29.20527351 + Sikkens K0.09.85217239210 + + + Sikkens K2.20.80184228175 + Sikkens K2.20.70163201155 + Sikkens K2.20.60137171128 + Sikkens K2.20.50119147113 + Sikkens K2.20.4510012698 + Sikkens K2.20.409211687 + Sikkens K2.20.30729268 + + + Sikkens K2.09.79199218192 + Sikkens K2.10.70177196169 + Sikkens K2.15.65161186154 + Sikkens K2.10.60154169150 + Sikkens K2.10.50127141121 + Sikkens K2.10.4010011295 + Sikkens K2.10.30809076 + + + Sikkens K2.10.20626758 + Sikkens KN.02.87236242230 + Sikkens K2.05.85228237223 + Sikkens K2.05.75201210196 + Sikkens K2.05.65167177164 + Sikkens K2.05.50130136126 + Sikkens K2.05.43114120111 + + + Sikkens K3.06.34939891 + Sikkens K2.05.25697466 + Sikkens LN.02.88232240234 + Sikkens LN.02.86230239232 + Sikkens LN.02.77204211202 + Sikkens LN.02.67180185178 + Sikkens LN.02.57151155150 + + + Sikkens LN.02.47124127122 + Sikkens LN.02.379910499 + Sikkens LN.02.27788177 + Sikkens L0.03.88216227217 + Sikkens L0.04.78203216204 + Sikkens L0.05.75194207194 + Sikkens L0.05.65169179170 + + + Sikkens L0.05.55143153143 + Sikkens L0.05.45117125117 + Sikkens L0.05.359410194 + Sikkens L0.05.25737973 + Sikkens L0.05.85219236220 + Sikkens L0.07.82208230210 + Sikkens L0.07.77196219198 + + + Sikkens L0.10.70178201180 + Sikkens L0.10.60144164147 + Sikkens L0.10.50126144129 + Sikkens L0.10.4098115101 + Sikkens L0.10.30799281 + Sikkens L3.10.82200233210 + Sikkens L0.15.75171212175 + + + Sikkens L0.15.65147184155 + Sikkens L0.15.55127157130 + Sikkens K8.23.4598135101 + Sikkens L0.20.30689473 + Sikkens L0.20.20487053 + Sikkens L0.15.20567058 + Sikkens L0.10.80192223197 + + + Sikkens L0.20.70150198159 + Sikkens L0.20.60129171135 + Sikkens L0.20.50108148115 + Sikkens L0.20.409111996 + Sikkens L0.30.30499260 + Sikkens L0.35.20417251 + Sikkens L0.20.15446250 + + + Sikkens L3.17.75153211176 + Sikkens L0.30.60101173123 + Sikkens L0.30.5086149103 + Sikkens L0.30.406412180 + Sikkens L0.40.355511573 + Sikkens L0.40.30409761 + Sikkens L0.30.20417148 + + + Sikkens L0.15.15455545 + Sikkens L0.30.70128209149 + Sikkens L0.40.6084185117 + Sikkens L0.50.503815381 + Sikkens L0.40.506614894 + Sikkens L0.40.404812073 + Sikkens L0.50.403113070 + + + Sikkens L0.70.40013157 + Sikkens L0.50.302811061 + Sikkens L9.13.79178226199 + Sikkens L5.21.73146211174 + Sikkens L4.32.66102200144 + Sikkens L8.40.6054186130 + Sikkens L5.47.526162105 + + + Sikkens L5.60.43014179 + Sikkens L5.63.33011465 + Sikkens L6.59.2308651 + Sikkens L8.20.70141204172 + Sikkens L8.30.70104206159 + Sikkens L8.30.6098182143 + Sikkens L8.50.500160103 + + + Sikkens L8.50.40013185 + Sikkens L8.40.352411783 + Sikkens L8.40.302310171 + Sikkens L8.50.30010370 + Sikkens L8.05.85219237223 + Sikkens L8.05.75191206196 + Sikkens L8.10.60146171157 + + + Sikkens L8.19.60116174145 + Sikkens L8.20.5099146122 + Sikkens L8.30.5073151113 + Sikkens L8.30.405812192 + Sikkens L8.30.30519675 + Sikkens M5.09.83205236219 + Sikkens L8.10.80188223202 + + + Sikkens L9.17.79172230201 + Sikkens M2.20.75138213186 + Sikkens M2.40.6046186140 + Sikkens M2.30.6090181146 + Sikkens M2.40.5018157118 + Sikkens M2.30.5072151121 + Sikkens M8.06.85214239228 + + + Sikkens N0.12.78176221207 + Sikkens N1.21.65119192177 + Sikkens N1.37.5528168147 + Sikkens N0.40.500150128 + Sikkens N0.50.500156130 + Sikkens N0.50.400129107 + Sikkens N0.40.30010187 + + + Sikkens M8.11.81192232217 + Sikkens M8.19.72139211195 + Sikkens N0.28.7099208183 + Sikkens N0.30.6085179160 + Sikkens N0.30.5060152133 + Sikkens N0.40.400128108 + Sikkens N0.30.4047119104 + + + Sikkens N0.40.2067866 + Sikkens N0.10.80188223214 + Sikkens N0.20.70137200184 + Sikkens N0.20.60120178162 + Sikkens N0.20.5095150136 + Sikkens N0.20.4073120108 + Sikkens N0.20.30589686 + + + Sikkens N0.20.20417063 + Sikkens N0.15.10415148 + Sikkens N0.05.85222236232 + Sikkens N8.09.81196231227 + Sikkens N0.10.70165199188 + Sikkens N0.10.60145174165 + Sikkens N0.10.50116141133 + + + Sikkens N0.10.4092113107 + Sikkens N0.10.30739085 + Sikkens N0.10.20596966 + Sikkens NN.01.84227229227 + Sikkens N7.04.73189204201 + Sikkens N6.08.70171196191 + Sikkens N1.06.88172189184 + + + Sikkens N0.05.65172184180 + Sikkens N1.05.55138152148 + Sikkens N0.05.44114124121 + Sikkens M8.06.349010197 + Sikkens PN.02.82218228226 + Sikkens PN.02.77203212210 + Sikkens P0.05.75191208204 + + + Sikkens P0.05.65165180178 + Sikkens P0.03.60160167165 + Sikkens P0.03.55141148148 + Sikkens P0.03.359110098 + Sikkens P0.05.25727877 + Sikkens P0.05.83211230226 + Sikkens P0.07.85212234230 + + + Sikkens P0.07.77191217214 + Sikkens P0.06.74185208205 + Sikkens P5.08.58144164164 + Sikkens P0.05.55138153150 + Sikkens P0.05.45119130128 + Sikkens P0.10.30719189 + Sikkens P1.03.79211222220 + + + Sikkens P0.04.78200216214 + Sikkens P0.10.70163197193 + Sikkens P1.11.63147178177 + Sikkens P0.10.60143170167 + Sikkens P0.10.50118144142 + Sikkens P0.10.4097117117 + Sikkens P0.10.20536765 + + + Sikkens P4.10.75178208207 + Sikkens P1.09.69166194193 + Sikkens P0.15.65138185181 + Sikkens P0.15.55116156153 + Sikkens P0.15.4598129126 + Sikkens P0.15.3576106104 + Sikkens P0.20.30579491 + + + Sikkens P0.20.20426968 + Sikkens P0.10.80189227223 + Sikkens P0.20.70129203197 + Sikkens P0.20.60110175170 + Sikkens P0.20.5094148145 + Sikkens P0.30.4051127123 + Sikkens P0.20.4078122120 + + + Sikkens P0.30.30379995 + Sikkens P0.30.25308583 + Sikkens P0.15.75161215209 + Sikkens P0.30.6080181178 + Sikkens P0.30.5059150145 + Sikkens P0.40.500155151 + Sikkens P0.50.400130127 + + + Sikkens P0.40.400126122 + Sikkens P0.50.30010299 + Sikkens P0.40.300104102 + Sikkens P2.11.80187227225 + Sikkens P2.20.73136210210 + Sikkens P2.32.6374190190 + Sikkens P3.35.5336160165 + + + Sikkens P3.40.440136138 + Sikkens P2.46.330107109 + Sikkens P4.48.2709396 + Sikkens Q0.40.2007581 + Sikkens Q0.30.6087174183 + Sikkens Q0.30.5060148156 + Sikkens P9.38.4917149162 + + + Sikkens Q0.40.400124135 + Sikkens Q0.50.400128141 + Sikkens Q0.50.300101111 + Sikkens Q0.40.30099105 + Sikkens Q0.50.2007582 + Sikkens P9.14.77170219224 + Sikkens Q0.20.73143210218 + + + Sikkens Q0.29.6598191203 + Sikkens Q0.40.5031151162 + Sikkens Q0.30.4039118126 + Sikkens Q0.30.304196103 + Sikkens Q0.30.20277175 + Sikkens Q1.19.10355153 + Sikkens Q0.09.81196225229 + + + Sikkens Q0.10.80190223225 + Sikkens Q0.20.70140199208 + Sikkens Q0.20.60122174180 + Sikkens Q0.20.5093144150 + Sikkens Q0.20.4078117121 + Sikkens Q0.20.30528892 + Sikkens Q0.20.20497477 + + + Sikkens Q0.05.84217234235 + Sikkens Q6.09.81201228234 + Sikkens Q6.10.80196227233 + Sikkens Q5.10.76187216222 + Sikkens Q0.10.70167197200 + Sikkens Q0.10.60145170173 + Sikkens Q0.10.50119141144 + + + Sikkens Q0.10.4097117120 + Sikkens QN.02.88238244243 + Sikkens QN.02.78215218217 + Sikkens P9.05.75192209210 + Sikkens Q0.05.65166182183 + Sikkens Q0.05.55147156156 + Sikkens Q5.05.44113123125 + + + Sikkens Q0.10.30759092 + Sikkens Q0.10.20526262 + Sikkens R0.05.85219232235 + Sikkens Q5.06.80203220224 + Sikkens R0.05.75196211214 + Sikkens Q9.12.74176209220 + Sikkens R0.10.60148169176 + + + Sikkens Q9.10.50120139145 + Sikkens Q9.10.4098114120 + Sikkens R3.10.30799297 + Sikkens R0.10.80195220227 + Sikkens Q7.15.76171216227 + Sikkens R0.20.70146199217 + Sikkens R0.20.60123170185 + + + Sikkens R0.20.50106145158 + Sikkens R0.30.4063121137 + Sikkens R0.30.304895108 + Sikkens R0.20.15375762 + Sikkens R0.13.77182217228 + Sikkens R1.17.73161206220 + Sikkens R1.23.72146205225 + + + Sikkens R0.30.60107175196 + Sikkens R0.30.5076148165 + Sikkens R0.40.4020122146 + Sikkens R0.40.30095111 + Sikkens R0.28.19367080 + Sikkens R2.20.73156208227 + Sikkens R2.22.66136189209 + + + Sikkens R2.29.68124197223 + Sikkens R2.33.5581162189 + Sikkens R0.40.5038152179 + Sikkens R0.50.400126154 + Sikkens R0.50.300101123 + Sikkens R1.35.23298095 + Sikkens R4.14.80175214225 + + + Sikkens R5.23.70130190214 + Sikkens R5.37.5673163197 + Sikkens R4.42.4743141175 + Sikkens R5.52.490146191 + Sikkens R6.56.360112147 + Sikkens R7.43.262287112 + Sikkens R9.35.22347797 + + + Sikkens R7.22.69145196221 + Sikkens R7.31.62112179213 + Sikkens R6.31.5288152179 + Sikkens R7.33.4777138166 + Sikkens R7.44.4133125160 + Sikkens R8.40.3945116148 + Sikkens R8.33.3653106130 + + + Sikkens S0.40.2017294 + Sikkens S0.30.60112172206 + Sikkens S0.40.6075176218 + Sikkens S0.50.500150199 + Sikkens S0.40.5060144185 + Sikkens S0.50.400123165 + Sikkens S0.40.4037118153 + + + Sikkens S0.50.30097133 + Sikkens S0.40.302493123 + Sikkens S4.26.64131183219 + Sikkens S1.38.5681163208 + Sikkens S4.43.4958144194 + Sikkens S4.43.4647136182 + Sikkens S3.56.420129186 + + + Sikkens S5.48.4119124173 + Sikkens S4.50.350104147 + Sikkens S5.44.301695131 + Sikkens S1.13.73177202218 + Sikkens S0.20.70158199222 + Sikkens S0.20.60131168189 + Sikkens S0.30.5086145174 + + + Sikkens S0.30.4070119144 + Sikkens S0.30.304892113 + Sikkens S1.37.253482108 + Sikkens S0.30.20347088 + Sikkens S0.10.80198224235 + Sikkens S0.15.80191219234 + Sikkens S0.15.65150181197 + + + Sikkens S0.15.55126154170 + Sikkens S0.20.50107141161 + Sikkens S0.20.4084116133 + Sikkens S0.20.306288101 + Sikkens S0.20.20517182 + Sikkens S0.05.85219232238 + Sikkens S0.11.78187215229 + + + Sikkens S0.10.70180199210 + Sikkens S0.10.60152168178 + Sikkens S0.10.50124141150 + Sikkens S0.10.40102114122 + Sikkens S0.10.30799096 + Sikkens S0.10.20566570 + Sikkens SN.02.85229235237 + + + Sikkens SN.02.82217223223 + Sikkens SN.02.77206210211 + Sikkens SN.02.67178185187 + Sikkens SN.02.57155158160 + Sikkens SN.02.47126128130 + Sikkens SN.02.37105107108 + Sikkens SN.02.27828484 + + + Sikkens S0.04.78205214217 + Sikkens S0.07.77198215222 + Sikkens S0.05.75193205210 + Sikkens S0.05.65167177182 + Sikkens S0.05.55146152154 + Sikkens S0.05.45120127131 + Sikkens S0.05.3596102105 + + + Sikkens S0.05.25727780 + Sikkens S6.09.77196211226 + Sikkens S2.10.71175196209 + Sikkens S3.10.73184205218 + Sikkens S8.10.65161177192 + Sikkens S8.12.55135152167 + Sikkens S8.15.4199118135 + + + Sikkens S8.10.3591101110 + Sikkens S8.14.17526373 + Sikkens S2.07.80206221231 + Sikkens S6.14.75183210231 + Sikkens S8.12.68170189207 + Sikkens S6.15.65157182205 + Sikkens S7.19.60137167194 + + + Sikkens S6.29.4382125157 + Sikkens S9.32.3562105137 + Sikkens S8.22.296788109 + Sikkens S7.17.72171202230 + Sikkens S6.21.66148186217 + Sikkens S7.27.61128174211 + Sikkens S8.34.5499155201 + + + Sikkens S9.35.56107161208 + Sikkens S9.39.4160120167 + Sikkens S8.48.28090135 + Sikkens T2.47.24082130 + Sikkens S8.16.73177204230 + Sikkens T0.20.70160191223 + Sikkens S9.25.65138181222 + + + Sikkens T0.30.60118168212 + Sikkens T0.40.5072143198 + Sikkens T0.50.400123184 + Sikkens T0.50.30095145 + Sikkens T0.45.203180114 + Sikkens T0.12.73181201222 + Sikkens S9.18.69160189218 + + + Sikkens T0.20.60134165194 + Sikkens T6.39.5296149213 + Sikkens T0.40.4052118166 + Sikkens T0.45.3531106153 + Sikkens T0.40.303791130 + Sikkens T0.40.202174111 + Sikkens T0.10.80208220235 + + + Sikkens T2.17.69167194223 + Sikkens T3.23.65149182223 + Sikkens T0.30.5095141180 + Sikkens T0.30.4078118153 + Sikkens T4.36.274686127 + Sikkens T0.30.305592121 + Sikkens T0.30.20386996 + + + Sikkens T3.06.78209217227 + Sikkens T0.13.64160177198 + Sikkens T4.18.59139164196 + Sikkens T3.20.50118142173 + Sikkens T0.23.48105136166 + Sikkens T4.27.3677105140 + Sikkens T4.40.303586128 + + + Sikkens T0.25.255579102 + Sikkens T3.06.80205215227 + Sikkens T0.10.70175191208 + Sikkens T0.10.60147162177 + Sikkens T3.14.55135152173 + Sikkens T0.20.50111138163 + Sikkens T0.20.4092116138 + + + Sikkens T0.20.306589110 + Sikkens T0.20.20526987 + Sikkens TN.02.82224226228 + Sikkens S7.04.85227235243 + Sikkens S7.05.75196204211 + Sikkens S8.05.65170177184 + Sikkens T0.10.50127138152 + + + Sikkens T0.10.40102113124 + Sikkens T0.10.307989100 + Sikkens T0.10.20596675 + Sikkens UN.02.82229228233 + Sikkens UN.02.85231233238 + Sikkens UN.02.77211211216 + Sikkens U0.05.75205209220 + + + Sikkens U0.05.65174177187 + Sikkens U0.05.55147151160 + Sikkens U0.05.45122125133 + Sikkens U0.05.359799106 + Sikkens U0.04.84229231236 + Sikkens T7.07.77203210227 + Sikkens T9.07.67176184197 + + + Sikkens U0.10.60159165183 + Sikkens U0.10.50128135154 + Sikkens U0.10.40107112128 + Sikkens U0.10.308389102 + Sikkens U0.10.20596373 + Sikkens T9.07.82219224240 + Sikkens T8.10.73190201222 + + + Sikkens U0.15.65160176208 + Sikkens U0.15.55134149176 + Sikkens U0.20.50123139174 + Sikkens U0.20.4096112142 + Sikkens U0.20.307085114 + Sikkens U0.20.20607092 + Sikkens U0.10.70181195217 + + + Sikkens U0.20.70170187223 + Sikkens U0.20.60144165206 + Sikkens U0.25.60135164209 + Sikkens U0.30.50111140194 + Sikkens U0.30.4083111159 + Sikkens U0.30.306891130 + Sikkens U0.30.20466498 + + + Sikkens U2.09.75189198220 + Sikkens U3.14.67171181211 + Sikkens U2.16.60151163195 + Sikkens U4.30.52121140201 + Sikkens U0.40.4069112173 + Sikkens U0.40.304986137 + Sikkens U0.40.203673116 + + + Sikkens U0.30.16395582 + Sikkens U3.07.78196204222 + Sikkens U3.20.64175191221 + Sikkens U3.19.64158174218 + Sikkens U4.25.55132149201 + Sikkens U4.35.4597121188 + Sikkens U5.44.356398173 + + + Sikkens U5.19.25445691 + Sikkens U2.21.09344360 + Sikkens U7.03.81210212222 + Sikkens U6.06.73198199216 + Sikkens U4.10.72193200224 + Sikkens U6.18.63164171215 + Sikkens U6.26.46113125175 + + + Sikkens U7.27.3692101149 + Sikkens U6.25.256776112 + Sikkens U4.15.10424558 + Sikkens VN.02.85233232236 + Sikkens U9.05.75203203217 + Sikkens V0.15.65177180212 + Sikkens V0.25.55137144199 + + + Sikkens V0.37.42103113185 + Sikkens V1.42.307284156 + Sikkens V0.40.307082148 + Sikkens V0.47.194462125 + Sikkens V0.07.80221220236 + Sikkens V0.10.70187187214 + Sikkens V0.20.60157159208 + + + Sikkens V0.20.50137141182 + Sikkens V0.25.45115120169 + Sikkens V0.30.40104111170 + Sikkens V0.30.307683132 + Sikkens U9.30.205864110 + Sikkens V7.03.81225222228 + Sikkens V2.08.71195194217 + + + Sikkens V5.14.60167161197 + Sikkens V4.25.55149142203 + Sikkens V5.25.45125117172 + Sikkens V0.25.359697141 + Sikkens V0.25.257175115 + Sikkens V3.35.14464797 + Sikkens V8.04.79216211224 + + + Sikkens V9.08.73205196220 + Sikkens V9.11.69189176208 + Sikkens V6.20.55155144192 + Sikkens V9.17.54147135181 + Sikkens V8.23.44129117166 + Sikkens V9.26.39115100156 + Sikkens V9.26.3310185136 + + + Sikkens W0.05.75210202219 + Sikkens W0.08.71201191214 + Sikkens W0.10.70196186214 + Sikkens W0.20.60172156201 + Sikkens W0.25.50147128186 + Sikkens W0.25.40122103160 + Sikkens W0.30.40120102165 + + + Sikkens W0.30.3010184141 + Sikkens W1.03.81227220231 + Sikkens W0.07.77213205225 + Sikkens W0.15.65183169209 + Sikkens W0.15.55155142180 + Sikkens W0.15.45131120153 + Sikkens W0.20.50147129179 + + + Sikkens W0.20.40119105150 + Sikkens W0.20.309381116 + Sikkens WN.02.82230225232 + Sikkens WN.02.77212207212 + Sikkens W0.05.70205197211 + Sikkens W0.10.60173161187 + Sikkens W0.10.50141136159 + + + Sikkens W0.10.40118109129 + Sikkens W0.10.309386106 + Sikkens W0.10.20575370 + Sikkens W0.03.84231223229 + Sikkens W0.04.78217212223 + Sikkens W0.05.65179175187 + Sikkens W0.05.55154148162 + + + Sikkens W0.05.45129123135 + Sikkens W0.05.3510499109 + Sikkens W0.05.25817788 + Sikkens W0.05.15585562 + Sikkens XN.01.83234228230 + Sikkens W6.05.72208196210 + Sikkens W8.05.63181170185 + + + Sikkens W6.07.53153142160 + Sikkens W7.07.44130119136 + Sikkens W7.07.3410696111 + Sikkens W7.08.24827387 + Sikkens X0.08.15645566 + Sikkens XN.01.84234227227 + Sikkens X0.04.80225213222 + + + Sikkens X0.10.70207186212 + Sikkens X0.10.60179157184 + Sikkens X0.10.50150130155 + Sikkens X0.10.40124106128 + Sikkens X0.10.309781100 + Sikkens X0.10.20695977 + Sikkens ZN.01.83237231230 + + + Sikkens X6.04.76220205217 + Sikkens X7.07.69204182199 + Sikkens X7.13.58184150179 + Sikkens X0.15.50158130167 + Sikkens X0.20.50158125172 + Sikkens X0.20.40131101141 + Sikkens X0.20.3010578112 + + + Sikkens X9.04.79230215224 + Sikkens X9.06.79236212229 + Sikkens X9.11.74230195221 + Sikkens X7.16.66211165205 + Sikkens X7.18.54179133173 + Sikkens X9.21.44157106148 + Sikkens X9.24.3312877118 + + + Sikkens Y0.30.18914889 + Sikkens YN.01.83236229230 + Sikkens YN.02.82234222225 + Sikkens Y1.08.73222194213 + Sikkens Y0.12.63200163191 + Sikkens Y0.20.50170122161 + Sikkens Y0.20.4013995133 + + + Sikkens Y0.20.3010870101 + Sikkens Y0.20.20915886 + Sikkens YN.02.77216206209 + Sikkens Y0.05.72211193200 + Sikkens Y0.10.70214181205 + Sikkens Y0.10.60188155179 + Sikkens Y0.10.50158130152 + + + Sikkens Y0.10.40129105124 + Sikkens Y0.10.30977893 + Sikkens Y0.10.20746073 + Sikkens ZN.01.85238230231 + Sikkens Z2.06.81239209217 + Sikkens Z1.10.70222180198 + Sikkens Z0.10.60188155169 + + + Sikkens Z0.10.50159127142 + Sikkens Z0.10.40135106118 + Sikkens Z0.10.301078293 + Sikkens Z0.05.20726468 + Sikkens Z8.05.81234207210 + Sikkens Z1.10.80252207226 + Sikkens Z1.14.71235178203 + + + Sikkens Z0.20.60211139174 + Sikkens Z0.20.50176121148 + Sikkens Z0.20.4014992122 + Sikkens Z0.20.301207197 + Sikkens Z0.19.20935878 + Sikkens Z9.10.77239191200 + Sikkens Z4.09.76236190205 + + + Sikkens Z6.23.60224128160 + Sikkens Z5.31.4819597140 + Sikkens Z3.37.4319181132 + Sikkens Z9.35.3817574110 + Sikkens Z2.32.3315269107 + Sikkens Z0.30.3013365103 + Sikkens Z8.11.75243194207 + + + Sikkens Z9.16.68235167186 + Sikkens Z8.18.63226151173 + Sikkens Z8.23.57215129157 + Sikkens Z9.32.4920999136 + Sikkens Z9.30.3515970100 + Sikkens Z8.23.271216584 + Sikkens Z3.23.241085477 + + diff --git a/src/ui/color_select/book.rs b/src/ui/color_select/book.rs index 59bbdba88..15fe2df1a 100644 --- a/src/ui/color_select/book.rs +++ b/src/ui/color_select/book.rs @@ -62,7 +62,7 @@ impl ColorBook { pub fn builtin() -> impl Iterator { [ include_str!("../../../assets/color_books/ral-classic.acb"), - include_str!("../../../assets/color_books/css-colors.acb"), + include_str!("../../../assets/color_books/sikkens-4041.acb"), ] .into_iter() .filter_map(|text| parse(text).ok()) diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index ea97384c7..47970c9ab 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -1,6 +1,6 @@ -//! The colour editor: HSB or RGB sliders, or a saturation/brightness field -//! with a hue strip, plus a hex box. Shared by the CAD Select Color panel and -//! the interface colour picker. +//! 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; @@ -15,19 +15,17 @@ 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; -/// Gap between slider rows. Together with `FIELD_HEIGHT` it puts the hex row -/// at the same height in every mode: 3 × 26 + 2 × 14 + 12 = 108 + 10. -const ROW_GAP: f32 = 14.0; -const FIELD_HEIGHT: f32 = 108.0; -const MODE_WIDTH: f32 = 40.0; +const ROW_GAP: f32 = 8.0; +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 { + Rgb, #[default] Hsb, - Rgb, - Field, + Cmyk, } #[derive(Debug, Clone)] @@ -43,6 +41,8 @@ pub enum EditorMsg { Red(u8), Green(u8), Blue(u8), + /// One CMYK channel (0 cyan … 3 black), in percent. + Cmyk(usize, f32), HexInput(String), HexSubmit, } @@ -51,6 +51,8 @@ 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, } @@ -64,6 +66,7 @@ impl ColorEditor { s: 0.0, b: 1.0, }, + cmyk: [0.0; 4], rgb, hex_draft: None, }; @@ -85,6 +88,7 @@ impl ColorEditor { 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; } @@ -96,9 +100,17 @@ impl ColorEditor { 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; @@ -118,6 +130,7 @@ impl ColorEditor { 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. @@ -138,28 +151,26 @@ impl ColorEditor { /// `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). - /// The field view shows the hex value beside H, S and B and leaves out - /// `hex_end`; the slider views end on a hex row. pub fn view<'a>( &'a self, mode_end: Option>, hex_end: Option>, ) -> Element<'a, Message> { - let view = column![self.mode_row(mode_end)].spacing(12); - match self.mode { - EditorMode::Hsb | EditorMode::Rgb => view - .push(self.slider_rows()) - .push(self.hex_row(hex_end)) - .into(), - EditorMode::Field => view.push(self.field_view()).into(), - } + column![ + self.mode_row(mode_end), + self.slider_rows(), + self.field(), + self.hex_row(hex_end), + ] + .spacing(12) + .into() } - /// HSB, RGB and the gradient-field button, with `end` at the far right. + /// RGB, HSB and CMYK, with `end` at the far right. fn mode_row<'a>(&self, end: Option>) -> Element<'a, Message> { - let mode_button = |content: Element<'a, Message>, mode: EditorMode| { + let mode_button = |label: &'static str, mode: EditorMode| { let active = self.mode == mode; - button(container(content).center(Length::Fill)) + button(container(text(label).size(13)).center(Length::Fill)) .on_press(msg(EditorMsg::Mode(mode))) .width(MODE_WIDTH) .height(MODE_HEIGHT) @@ -168,35 +179,10 @@ impl ColorEditor { super::picker::toggle_style(theme, active, status) }) }; - // The field button is only its gradient, without a frame; it takes - // colour when its mode is active and stays gray otherwise. - let field_active = self.mode == EditorMode::Field; - let field_button = button(Space::new().width(MODE_WIDTH).height(MODE_HEIGHT)) - .on_press(msg(EditorMsg::Mode(EditorMode::Field))) - .padding(0) - .style(move |_theme: &Theme, _status| { - let middle = if field_active { - Color::from_rgb8(0x3A, 0xA0, 0xFF) - } else { - Color::from_rgb8(0x80, 0x80, 0x80) - }; - let icon = gradient_box( - 3.0 * std::f32::consts::FRAC_PI_4, - &[(0.0, Color::WHITE), (0.5, middle), (1.0, Color::BLACK)], - ); - button::Style { - background: icon.background, - border: Border { - radius: 5.0.into(), - ..Border::default() - }, - ..Default::default() - } - }); let mut modes = row![ - mode_button(text("HSB").size(13).into(), EditorMode::Hsb), - mode_button(text("RGB").size(13).into(), EditorMode::Rgb), - field_button, + mode_button("RGB", EditorMode::Rgb), + mode_button("HSB", EditorMode::Hsb), + mode_button("CMYK", EditorMode::Cmyk), Space::new().width(Length::Fill), ] .spacing(6); @@ -206,12 +192,29 @@ impl ColorEditor { modes.into() } - /// Three gradient sliders: hue, saturation and brightness, or red, green - /// and blue. + /// 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::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))] }; @@ -266,9 +269,8 @@ impl ColorEditor { rows.spacing(ROW_GAP).into() } - /// The saturation/brightness field with its hue strip, and below it H, S - /// and B on the left and the hex value on the right. - fn field_view<'a>(&self) -> Element<'a, Message> { + /// The saturation/brightness field with its hue strip. + fn field<'a>(&self) -> 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)) @@ -285,50 +287,17 @@ impl ColorEditor { .height(FIELD_HEIGHT) .style(handle_style), ]; - let value = - |letter: &'static str, v: f32, max: f32, unit: char, f: fn(f32) -> EditorMsg| { - row![ - text(letter).size(12).font(iced::Font::MONOSPACE), - number_box(v, max, Some(unit), f), - ] - .spacing(4) - .align_y(iced::Center) - }; - column![ - row![area, strip].spacing(12), - row![ - value("H", h, 360.0, '°', EditorMsg::Hue), - value("S", s * 100.0, 100.0, '%', EditorMsg::Saturation), - value("B", b * 100.0, 100.0, '%', EditorMsg::Brightness), - Space::new().width(Length::Fill), - self.hex_field(), - ] - .spacing(10) - .align_y(iced::Center), - ] - // 108 + 10 matches the slider views' 3 × 26 + 2 × 14 + 12. - .spacing(10) - .into() + 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 mut hex_row = row![self.hex_field(), 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 hex_field<'a>(&self) -> Element<'a, Message> { let (r, g, b) = self.rgb; let hex = self .hex_draft .clone() .unwrap_or_else(|| math::hex_string(r, g, b)); - row![ + 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))) @@ -337,10 +306,14 @@ impl ColorEditor { .font(iced::Font::MONOSPACE) .padding([5, 6]) .width(Length::Fixed(74.0)), + Space::new().width(Length::Fill), ] .spacing(8) - .align_y(iced::Center) - .into() + .align_y(iced::Center); + if let Some(end) = end { + hex_row = hex_row.push(end); + } + hex_row.into() } } diff --git a/src/ui/color_select/math.rs b/src/ui/color_select/math.rs index 74ab9968d..b62250425 100644 --- a/src/ui/color_select/math.rs +++ b/src/ui/color_select/math.rs @@ -59,6 +59,24 @@ pub fn rgb_to_hsb(r: u8, g: u8, b: u8) -> Hsb { 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) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index a4313f881..c02c3723d 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -24,7 +24,7 @@ const PANEL_WIDTH: f32 = 400.0; /// Scope for Tab / Shift+Tab, so focus cycles through the panel's own /// fields instead of the dialogs and command line underneath. const PANEL_ID: &str = "select-color-panel"; -const BODY_HEIGHT: f32 = 352.0; +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. @@ -32,8 +32,8 @@ const PAGE_BARS: usize = 7; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ColorPickerTab { - Document, #[default] + Document, Index, TrueColor, Books, @@ -106,11 +106,6 @@ pub struct ColorPicker { impl ColorPicker { pub fn new(target: ColorPickTarget, current: AcadColor, varies: bool) -> Self { - let tab = if matches!(current, AcadColor::Rgb { .. }) { - ColorPickerTab::TrueColor - } else { - ColorPickerTab::Index - }; Self { target, current, @@ -118,7 +113,7 @@ impl ColorPicker { pending: current, current_name: None, pending_name: None, - tab, + tab: ColorPickerTab::Document, editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))), eyedropper: None, doc: Vec::new(), @@ -133,8 +128,7 @@ impl ColorPicker { } /// Fill the Document and Color books tabs. `current_name` is the - /// `BOOK$NAME` of the current colour; a colour from a loaded book opens - /// on its page. + /// `BOOK$NAME` of the current colour; the Color books tab shows its page. pub fn with_drawing( mut self, mut doc: Vec, @@ -147,9 +141,7 @@ impl ColorPicker { self.recent = recent.to_vec(); self.books = books; if let Some(name) = current_name.filter(|name| !name.is_empty()) { - if self.show_identity(&name) { - self.tab = ColorPickerTab::Books; - } + self.show_identity(&name); self.current_name = Some(name.clone()); self.pending_name = Some(name); } @@ -1152,13 +1144,12 @@ mod tests { } #[test] - fn opens_on_the_tab_that_fits_the_current_colour() { - assert_eq!(picker(AcadColor::Index(30)).tab(), ColorPickerTab::Index); + 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::TrueColor + ColorPickerTab::Document ); - assert_eq!(picker(AcadColor::ByLayer).tab(), ColorPickerTab::Index); } #[test] @@ -1225,6 +1216,7 @@ mod tests { 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)); } @@ -1313,7 +1305,6 @@ mod tests { b: 179, }) .with_drawing(Vec::new(), &[], ral(), Some("RAL CLASSIC$RAL 5015".into())); - assert_eq!(p.tab(), ColorPickerTab::Books); assert_eq!(p.book_selection(), Some(2)); assert_eq!(p.book_page, 1); } @@ -1388,6 +1379,7 @@ mod tests { 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"); @@ -1489,11 +1481,11 @@ mod tests { false, ), ( - "field", + "cmyk", ColorPickTarget::Properties, blue, ColorPickerTab::TrueColor, - EditorMode::Field, + EditorMode::Cmyk, false, ), ( From b039e3dc485ce123a11ac5f9f0aa45dada7d70f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:13:00 +0000 Subject: [PATCH 18/32] Select Color: bottom-aligned field, list view, name sorting, book names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - True Color opens in RGB; the gradient field, hex and nearest index sit at the bottom so CMYK's fourth slider doesn't move them; slider rows closer together. - Document tab: grid or list view (list drawn like the colour-book bars), and Name A–Z / Z–A sorting (translated in all locales). Swatches of colour-book colours show the colour's name. - A colour-book colour picked as the current colour (nothing selected) keeps its BOOK$NAME on the objects drawn with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- assets/icons/ui/list.svg | 1 + locales/ar-SA/opencadstudio.ftl | 2 + locales/bg-BG/opencadstudio.ftl | 2 + locales/cs-CZ/opencadstudio.ftl | 2 + locales/de-DE/opencadstudio.ftl | 2 + locales/el-GR/opencadstudio.ftl | 2 + locales/en-US/opencadstudio.ftl | 2 + locales/es-ES/opencadstudio.ftl | 2 + locales/fi-FI/opencadstudio.ftl | 2 + locales/fr-FR/opencadstudio.ftl | 2 + locales/hi-IN/opencadstudio.ftl | 2 + locales/hu-HU/opencadstudio.ftl | 2 + locales/it-IT/opencadstudio.ftl | 2 + locales/ja-JP/opencadstudio.ftl | 2 + locales/ko-KR/opencadstudio.ftl | 2 + locales/lo-LA/opencadstudio.ftl | 2 + locales/nl-NL/opencadstudio.ftl | 2 + locales/pl-PL/opencadstudio.ftl | 2 + locales/pt-BR/opencadstudio.ftl | 2 + locales/ru-RU/opencadstudio.ftl | 2 + locales/tr-TR/opencadstudio.ftl | 2 + locales/zh-CN/opencadstudio.ftl | 2 + locales/zh-TW/opencadstudio.ftl | 2 + src/app/command_driver/entity_commit.rs | 12 ++- src/app/properties.rs | 6 +- src/app/update/mod.rs | 4 + src/app/update/style.rs | 6 +- src/locale_catalog.rs | 2 + src/ui/color_select/document.rs | 46 ++++++--- src/ui/color_select/editor.rs | 20 ++-- src/ui/color_select/picker.rs | 119 ++++++++++++++++++------ src/ui/icons.rs | 1 + src/ui/ribbon/mod.rs | 13 +++ 33 files changed, 221 insertions(+), 53 deletions(-) create mode 100644 assets/icons/ui/list.svg 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/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 8f492e2b9..31895c7a5 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .color-recently-used = المستخدمة مؤخرًا .color-most-used = الأكثر استخدامًا .color-number = رقم اللون + .color-name-az = الاسم أ–ي + .color-name-za = الاسم ي–أ .color-in-drawing = الألوان في هذا الرسم .color-sort = فرز .color-no-drawing-colors = لا توجد ألوان في هذا الرسم بعد diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 89a531b0b..43e184ba9 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .color-recently-used = Наскоро използвани .color-most-used = Най-често използвани .color-number = Номер на цвят + .color-name-az = Име А–Я + .color-name-za = Име Я–А .color-in-drawing = Цветове в този чертеж .color-sort = Сортиране .color-no-drawing-colors = Все още няма цветове в този чертеж diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 36bc5f400..a16245e9e 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .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-in-drawing = Barvy v tomto výkresu .color-sort = Řadit .color-no-drawing-colors = V tomto výkresu zatím nejsou žádné barvy diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 518150aa6..8e2199112 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1347,6 +1347,8 @@ common = .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-in-drawing = Farben in dieser Zeichnung .color-sort = Sortieren .color-no-drawing-colors = Noch keine Farben in dieser Zeichnung diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 9eb330ec7..cf52e3306 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1348,6 +1348,8 @@ common = .color-recently-used = Πρόσφατα χρησιμοποιημένα .color-most-used = Πιο συχνά χρησιμοποιημένα .color-number = Αριθμός χρώματος + .color-name-az = Όνομα Α–Ω + .color-name-za = Όνομα Ω–Α .color-in-drawing = Χρώματα σε αυτό το σχέδιο .color-sort = Ταξινόμηση .color-no-drawing-colors = Δεν υπάρχουν ακόμη χρώματα σε αυτό το σχέδιο diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 9c743dcdd..39c221c99 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1347,6 +1347,8 @@ common = .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-in-drawing = Colors in this drawing .color-sort = Sort .color-no-drawing-colors = No colors in this drawing yet diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index a07925a3f..1459af092 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1347,6 +1347,8 @@ common = .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-in-drawing = Colores de este dibujo .color-sort = Ordenar .color-no-drawing-colors = Aún no hay colores en este dibujo diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 59b67a113..24e76f90a 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .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-in-drawing = Tämän piirustuksen värit .color-sort = Lajittele .color-no-drawing-colors = Tässä piirustuksessa ei ole vielä värejä diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 0068f6c15..b13f28875 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1347,6 +1347,8 @@ common = .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-in-drawing = Couleurs de ce dessin .color-sort = Trier .color-no-drawing-colors = Aucune couleur dans ce dessin pour l'instant diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 05aa07fd8..03b09327c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1335,6 +1335,8 @@ common = .color-recently-used = हाल ही में उपयोग किए गए .color-most-used = सबसे अधिक उपयोग किए गए .color-number = रंग संख्या + .color-name-az = नाम A–Z + .color-name-za = नाम Z–A .color-in-drawing = इस ड्रॉइंग के रंग .color-sort = क्रमबद्ध करें .color-no-drawing-colors = इस ड्रॉइंग में अभी तक कोई रंग नहीं है diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 1e75ed541..95533f1eb 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .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-in-drawing = Színek ebben a rajzban .color-sort = Rendezés .color-no-drawing-colors = Ebben a rajzban még nincsenek színek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 241c4f721..d20cd6253 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .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-in-drawing = Colori in questo disegno .color-sort = Ordina .color-no-drawing-colors = Ancora nessun colore in questo disegno diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 3e3d9709d..341ca7b0c 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1337,6 +1337,8 @@ common = .color-recently-used = 最近使用した色 .color-most-used = 使用頻度の高い色 .color-number = 色番号 + .color-name-az = 名前 (昇順) + .color-name-za = 名前 (降順) .color-in-drawing = この図面の色 .color-sort = 並べ替え .color-no-drawing-colors = この図面にはまだ色がありません diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 0c6364171..8feda62e0 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .color-recently-used = 최근 사용 .color-most-used = 가장 많이 사용 .color-number = 색상 번호 + .color-name-az = 이름 오름차순 + .color-name-za = 이름 내림차순 .color-in-drawing = 이 도면의 색상 .color-sort = 정렬 .color-no-drawing-colors = 이 도면에는 아직 색상이 없습니다 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 859458ff0..69971103d 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1352,6 +1352,8 @@ common = .color-recently-used = ໃຊ້ຫຼ້າສຸດ .color-most-used = ໃຊ້ຫຼາຍທີ່ສຸດ .color-number = ເລກສີ + .color-name-az = ຊື່ A–Z + .color-name-za = ຊື່ Z–A .color-in-drawing = ສີໃນແບບແຕ້ມນີ້ .color-sort = ຈັດລຽງ .color-no-drawing-colors = ຍັງບໍ່ມີສີໃນແບບແຕ້ມນີ້ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index f7d735880..8be90e920 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1345,6 +1345,8 @@ common = .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-in-drawing = Kleuren in deze tekening .color-sort = Sorteren .color-no-drawing-colors = Nog geen kleuren in deze tekening diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 0852391c9..b7408ba68 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .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-in-drawing = Kolory w tym rysunku .color-sort = Sortuj .color-no-drawing-colors = Brak kolorów w tym rysunku diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 33fa9e2e8..1eb42528f 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1347,6 +1347,8 @@ common = .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-in-drawing = Cores neste desenho .color-sort = Ordenar .color-no-drawing-colors = Ainda não há cores neste desenho diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 4778249cf..98ae70a65 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1350,6 +1350,8 @@ common = .color-recently-used = Недавно использованные .color-most-used = Чаще всего используемые .color-number = Номер цвета + .color-name-az = Имя А–Я + .color-name-za = Имя Я–А .color-in-drawing = Цвета в этом чертеже .color-sort = Сортировка .color-no-drawing-colors = В этом чертеже пока нет цветов diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index c94918f97..ce888b89b 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1333,6 +1333,8 @@ common = .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-in-drawing = Bu çizimdeki renkler .color-sort = Sırala .color-no-drawing-colors = Bu çizimde henüz renk yok diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index d14c18f2c..a0c09867c 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1334,6 +1334,8 @@ common = .color-recently-used = 最近使用 .color-most-used = 最常使用 .color-number = 颜色编号 + .color-name-az = 名称 A–Z + .color-name-za = 名称 Z–A .color-in-drawing = 此图形中的颜色 .color-sort = 排序 .color-no-drawing-colors = 此图形中还没有颜色 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index fec601b75..fc5989507 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .color-recently-used = 最近使用 .color-most-used = 最常使用 .color-number = 顏色編號 + .color-name-az = 名稱 A–Z + .color-name-za = 名稱 Z–A .color-in-drawing = 此圖面中的顏色 .color-sort = 排序 .color-no-drawing-colors = 此圖面中還沒有顏色 diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index 85c3dfc19..3416e9015 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/properties.rs b/src/app/properties.rs index c4b2ff463..4d82fcc7b 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -3149,7 +3149,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 1211eff5b..1e4b217b2 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11699,6 +11699,9 @@ impl OpenCADStudio { }; 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(|layer| { match (&layer.book_name, &layer.color_name) { @@ -11767,6 +11770,7 @@ impl OpenCADStudio { 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; } diff --git a/src/app/update/style.rs b/src/app/update/style.rs index fc0db8af7..dfd15aff9 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1215,7 +1215,11 @@ 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::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, &layer) { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 18ecb5ed0..80b1bf903 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4723,6 +4723,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "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")), "Colors in this drawing" => Some(("common", "color-in-drawing")), "Sort" => Some(("common", "color-sort")), "No colors in this drawing yet" => Some(("common", "color-no-drawing-colors")), diff --git a/src/ui/color_select/document.rs b/src/ui/color_select/document.rs index 9f976b66d..bbd1d0a88 100644 --- a/src/ui/color_select/document.rs +++ b/src/ui/color_select/document.rs @@ -21,21 +21,35 @@ pub struct DocColor { } impl DocColor { - /// The few characters drawn on the swatch: the index number, `RGB`, or - /// the first word of the colour book. + /// The text drawn on the swatch: the index number, `RGB`, or the + /// colour-book colour's own name. pub fn tag(&self) -> String { match (&self.name, self.color) { - (Some(name), _) => name - .split(['$', ' ']) - .next() - .unwrap_or_default() - .chars() - .take(5) - .collect(), + (Some(_), _) => self.label(), (None, AcadColor::Index(i)) => i.to_string(), _ => "RGB".to_string(), } } + + /// 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)] @@ -45,14 +59,18 @@ pub enum DocSort { Usage, Hue, Number, + NameAsc, + NameDesc, } impl DocSort { - pub const ALL: [DocSort; 4] = [ + pub const ALL: [DocSort; 6] = [ DocSort::Recent, DocSort::Usage, DocSort::Hue, DocSort::Number, + DocSort::NameAsc, + DocSort::NameDesc, ]; } @@ -63,6 +81,8 @@ impl std::fmt::Display for DocSort { 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"), }) } } @@ -163,6 +183,10 @@ pub fn sort(colors: &mut [DocColor], by: DocSort, recent: &[AcadColor]) { .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())) + } } } @@ -201,7 +225,7 @@ mod tests { assert_eq!(colors[0].uses, 3); assert_eq!(colors[0].newest, Handle::new(3)); assert_eq!(colors[3].name, None, "an empty name is no name"); - assert_eq!(colors[2].tag(), "RAL"); + assert_eq!(colors[2].tag(), "RAL 5015"); assert_eq!(colors[0].tag(), "1"); assert_eq!(colors[1].tag(), "RGB"); } diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index 47970c9ab..4249e4c0f 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -15,15 +15,15 @@ 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 = 8.0; +const ROW_GAP: f32 = 6.0; 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 { - Rgb, #[default] + Rgb, Hsb, Cmyk, } @@ -151,19 +151,19 @@ impl ColorEditor { /// `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 and hex row sit at the bottom of the space, so + /// they stay put when CMYK adds a fourth slider. pub fn view<'a>( &'a self, mode_end: Option>, hex_end: Option>, + fill: bool, ) -> Element<'a, Message> { - column![ - self.mode_row(mode_end), - self.slider_rows(), - self.field(), - self.hex_row(hex_end), - ] - .spacing(12) - .into() + let mut view = column![self.mode_row(mode_end), self.slider_rows()].spacing(12); + if fill { + view = view.push(Space::new().height(Length::Fill)); + } + view.push(self.field()).push(self.hex_row(hex_end)).into() } /// RGB, HSB and CMYK, with `end` at the far right. diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index c02c3723d..c6cefccc3 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -6,7 +6,7 @@ //! colour editor: no tabs, no index colours. use super::book::ColorBook; -use super::document::{self, DocColor, DocSort}; +use super::document::{self, DocColor, DocSort, DocView}; use super::editor::{rgb_color, ColorEditor, EditorMsg}; use super::eyedropper::{Eyedropper, EyedropperMsg}; use super::math; @@ -54,6 +54,7 @@ pub enum ColorPickerMsg { /// A colour of the Document tab, by its place in the list. PickDocument(usize), DocSort(DocSort), + DocView(DocView), /// Show another loaded colour book. Book(usize), BookPage(usize), @@ -94,6 +95,7 @@ pub struct ColorPicker { eyedropper: Option, doc: Vec, doc_sort: DocSort, + doc_view: DocView, /// This drawing's colours picked this session, newest first. recent: Vec, books: Arc>, @@ -118,6 +120,7 @@ impl ColorPicker { eyedropper: None, doc: Vec::new(), doc_sort: DocSort::default(), + doc_view: DocView::default(), recent: Vec::new(), books: Arc::default(), book: 0, @@ -284,6 +287,7 @@ impl ColorPicker { self.pending_name = name; } } + ColorPickerMsg::DocView(view) => self.doc_view = view, ColorPickerMsg::DocSort(by) => { self.doc_sort = by; document::sort(&mut self.doc, by, &self.recent); @@ -358,8 +362,8 @@ impl ColorPicker { // height across its tabs, with the info line at the bottom. let mut body = column![body].spacing(8); if !editor_only { - // Document and Color books fill the height themselves. - if matches!(self.tab, ColorPickerTab::Index | ColorPickerTab::TrueColor) { + // The other tabs fill the height themselves. + if self.tab == ColorPickerTab::Index { body = body.push(Space::new().height(Length::Fill)); } let fill = @@ -578,6 +582,11 @@ impl ColorPicker { .on_select(|by| picker_msg(ColorPickerMsg::DocSort(by))) .text_size(12) .padding([3, 6]), + row![ + self.view_button(crate::ui::icons::GRID, DocView::Grid), + self.view_button(crate::ui::icons::LIST, DocView::List), + ] + .spacing(2), ] .spacing(6) .align_y(iced::Center); @@ -589,9 +598,59 @@ impl ColorPicker { .spacing(10) .into(); } - let grid = self.doc.chunks(DOC_COLUMNS).enumerate().fold( - column![].spacing(4), - |grid, (r, chunk)| { + let list: Element<'_, Message> = if self.doc_view == DocView::List { + self.doc + .iter() + .enumerate() + .fold(column![].spacing(2), |list, (i, entry)| { + let fill = math::color_rgb(entry.color) + .map_or(Color::from_rgb8(128, 128, 128), rgb_color); + let selected = self.pending == entry.color && self.pending_name == entry.name; + list.push( + color_bar( + entry.label(), + fill, + selected, + ColorPickerMsg::PickDocument(i), + ) + .height(26), + ) + }) + .into() + } else { + self.doc_grid() + }; + 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. + fn view_button(&self, icon: &'static [u8], view: DocView) -> Element<'_, Message> { + let active = self.doc_view == view; + button( + svg(crate::ui::icons::themed_handle(icon)) + .width(14) + .height(14), + ) + .on_press(picker_msg(ColorPickerMsg::DocView(view))) + .padding(5) + .style(move |theme: &Theme, status| toggle_style(theme, active, status)) + .into() + } + + fn doc_grid(&self) -> Element<'_, Message> { + self.doc + .chunks(DOC_COLUMNS) + .enumerate() + .fold(column![].spacing(4), |grid, (r, chunk)| { let line = chunk .iter() .enumerate() @@ -601,18 +660,8 @@ impl ColorPicker { let line = (chunk.len()..DOC_COLUMNS) .fold(line, |line, _| line.push(Space::new().width(Length::Fill))); grid.push(line) - }, - ); - column![ - header, - scrollable(grid).height(Length::Fill).spacing(4).direction( - scrollable::Direction::Vertical( - scrollable::Scrollbar::new().width(4).scroller_width(4) - ) - ), - ] - .spacing(10) - .into() + }) + .into() } fn doc_swatch<'a>(&self, i: usize, entry: &'a DocColor) -> Element<'a, Message> { @@ -627,7 +676,8 @@ impl ColorPicker { ) .align_y(iced::Bottom) .padding([1, 4]) - .height(Length::Fill), + .height(Length::Fill) + .clip(true), ) .on_press(picker_msg(ColorPickerMsg::PickDocument(i))) .padding(0) @@ -863,7 +913,7 @@ impl ColorPicker { ) }; let nearest = (!self.editor_only()).then(|| self.nearest_button()); - self.editor.view(pipette, nearest) + self.editor.view(pipette, nearest, !self.editor_only()) } fn nearest_button(&self) -> Element<'_, Message> { @@ -985,24 +1035,37 @@ fn book_bar( color: &super::book::BookColor, selected: Option, ) -> Element<'_, Message> { - let fill = rgb_color(color.rgb); + 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(&color.name) + text(name) .size(12) .font(iced::Font::MONOSPACE) .color(ink(fill)), ) .center(Length::Fill), ) - .on_press(picker_msg(ColorPickerMsg::PickBook(i))) + .on_press(picker_msg(on_press)) .padding(0) .width(Length::Fill) - .height(Length::Fill) - .style(move |theme: &Theme, status| { - swatch_button_style(theme, status, fill, selected == Some(i)) - }) - .into() + .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) } /// A short gray note in the middle of the free space. diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 35caaca19..a37c7ccdd 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -81,6 +81,7 @@ 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 SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg"); pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg"); pub static DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg"); diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 149f4e9a1..0d3777e2d 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -82,6 +82,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. @@ -171,6 +174,15 @@ fn position_ribbon_dropdown<'a>( } impl Ribbon { + /// 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(), @@ -195,6 +207,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 { From 652aa61b22f446958bb5683394a9fa2bd424ce4f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:29:19 +0000 Subject: [PATCH 19/32] Select Color: gradient field fills the space under the sliders The field no longer has a fixed height with a gap above it; it takes the height the sliders leave, so in CMYK it is just one slider row lower. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/editor.rs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index 4249e4c0f..90604ee01 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -16,6 +16,7 @@ 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; @@ -151,19 +152,28 @@ impl ColorEditor { /// `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 and hex row sit at the bottom of the space, so - /// they stay put when CMYK adds a fourth slider. + /// 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 mut view = column![self.mode_row(mode_end), self.slider_rows()].spacing(12); - if fill { - view = view.push(Space::new().height(Length::Fill)); - } - view.push(self.field()).push(self.hex_row(hex_end)).into() + 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. @@ -270,21 +280,21 @@ impl ColorEditor { } /// The saturation/brightness field with its hue strip. - fn field<'a>(&self) -> Element<'a, Message> { + 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(Length::Fixed(FIELD_HEIGHT)); + .height(height); // A vertical slider grows upwards; the strip runs red at the top. let strip = stack![ container(Space::new()) .width(STRIP_WIDTH) - .height(FIELD_HEIGHT) + .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(FIELD_HEIGHT) + .height(height) .style(handle_style), ]; row![area, strip].spacing(12).into() From 5a296ad48c18cfa4218422cefc6c23f4ced11d10 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:50:58 +0000 Subject: [PATCH 20/32] Select Color and ribbon: ByLayer shows the layer's colour ByLayer swatches were always gray. The Select Color panel's Current/New band and info line now show the colour of the layer the colour is for (the newest selected object's layer, or the current layer for new objects); the ribbon's colour control and the ByLayer row of its colour dropdown show the current layer's colour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/update/mod.rs | 25 +++++++++++++++++++++++ src/ui/color_select/picker.rs | 35 ++++++++++++++++++++++++++------- src/ui/ribbon/color_dropdown.rs | 10 +++++++--- src/ui/ribbon/mod.rs | 14 ++++++++++++- src/ui/ribbon/widgets.rs | 7 ++++++- 5 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1e4b217b2..01630caea 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11563,12 +11563,14 @@ impl OpenCADStudio { 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); @@ -11734,6 +11736,29 @@ impl OpenCADStudio { (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 books and the ones remembered in the /// settings, once per session. A loaded book replaces a built-in one of /// the same name. diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index c6cefccc3..cf4090223 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -104,6 +104,8 @@ pub struct ColorPicker { 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 { @@ -127,18 +129,23 @@ impl ColorPicker { 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(); @@ -330,6 +337,14 @@ impl ColorPicker { 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 { @@ -366,8 +381,7 @@ impl ColorPicker { if self.tab == ColorPickerTab::Index { body = body.push(Space::new().height(Length::Fill)); } - let fill = - math::color_rgb(self.pending).map_or(Color::from_rgb8(128, 128, 128), rgb_color); + let fill = self.shown(self.pending); let info = match (&self.status, &self.pending_name) { (Some(status), _) => status.clone(), (None, Some(name)) => { @@ -469,8 +483,8 @@ impl ColorPicker { .width(Length::Fill) .height(Length::Fill) }; - let (current_bg, _) = acad_color_display(self.current); - let (new_bg, _) = acad_color_display(self.pending); + 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 @@ -1336,7 +1350,7 @@ mod tests { #[test] fn book_colours_keep_their_name_until_edited() { - let mut p = picker(AcadColor::Index(30)).with_drawing(Vec::new(), &[], ral(), None); + 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!( @@ -1367,7 +1381,13 @@ mod tests { g: 113, b: 179, }) - .with_drawing(Vec::new(), &[], ral(), Some("RAL CLASSIC$RAL 5015".into())); + .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); } @@ -1377,7 +1397,7 @@ mod tests { // 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); + 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!( @@ -1587,6 +1607,7 @@ mod tests { &[], books, None, + None, ); p.update(ColorPickerMsg::Tab(tab)); if name == "books" { 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 0d3777e2d..c67e39bdd 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -174,6 +174,14 @@ 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 { + self.layer_infos + .iter() + .find(|layer| layer.name == self.active_layer) + .map(|layer| layer.color) + } + /// 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 { @@ -1232,7 +1240,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 989ea879b..fdd53ee38 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -1167,7 +1167,12 @@ 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 = layer_infos + .iter() + .find(|layer| layer.name == active_layer) + .filter(|_| active_color == AcadColor::ByLayer) + .map_or_else(|| acad_color_display(active_color).0, |layer| layer.color); let color_row = prop_row( crate::ui::color_select::color_display_name(active_color), PROP_COLOR_ID, From e13e93966bcc65b0b6a6a8f8b18844cc0876386e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:46:45 +0000 Subject: [PATCH 21/32] Select Color: search boxes, readable icon buttons, cleaner Document and Color books layout - Info line shows a colour-book colour as "BOOK - NAME". - Document tab: a search box (by name) replaces the "Colors in this drawing" heading; grid swatches no longer carry text; larger grid/list icons, dark on the accent colour when active, like the OK button. - Color books tab: no "Color book"/"Find" labels; the book list is as wide as the colour bars, and the search box (magnifier, gray "Search") and load button sit over the page strip and RGB values, all 28 px high; the load button and page arrows use the button's dark text colour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- assets/icons/ui/search.svg | 1 + locales/ar-SA/opencadstudio.ftl | 1 - locales/bg-BG/opencadstudio.ftl | 1 - locales/cs-CZ/opencadstudio.ftl | 1 - locales/de-DE/opencadstudio.ftl | 1 - locales/el-GR/opencadstudio.ftl | 1 - locales/en-US/opencadstudio.ftl | 1 - locales/es-ES/opencadstudio.ftl | 1 - locales/fi-FI/opencadstudio.ftl | 1 - locales/fr-FR/opencadstudio.ftl | 1 - locales/hi-IN/opencadstudio.ftl | 1 - locales/hu-HU/opencadstudio.ftl | 1 - locales/it-IT/opencadstudio.ftl | 1 - locales/ja-JP/opencadstudio.ftl | 1 - locales/ko-KR/opencadstudio.ftl | 1 - locales/lo-LA/opencadstudio.ftl | 1 - locales/nl-NL/opencadstudio.ftl | 1 - locales/pl-PL/opencadstudio.ftl | 1 - locales/pt-BR/opencadstudio.ftl | 1 - locales/ru-RU/opencadstudio.ftl | 1 - locales/tr-TR/opencadstudio.ftl | 1 - locales/zh-CN/opencadstudio.ftl | 1 - locales/zh-TW/opencadstudio.ftl | 1 - src/locale_catalog.rs | 1 - src/ui/color_select/document.rs | 16 +-- src/ui/color_select/picker.rs | 203 ++++++++++++++++++++------------ src/ui/icons.rs | 1 + 27 files changed, 134 insertions(+), 110 deletions(-) create mode 100644 assets/icons/ui/search.svg 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 31895c7a5..643e7dab9 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1361,7 +1361,6 @@ common = .color-number = رقم اللون .color-name-az = الاسم أ–ي .color-name-za = الاسم ي–أ - .color-in-drawing = الألوان في هذا الرسم .color-sort = فرز .color-no-drawing-colors = لا توجد ألوان في هذا الرسم بعد .color-books = كتب الألوان diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 43e184ba9..8bc2bce14 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1348,7 +1348,6 @@ common = .color-number = Номер на цвят .color-name-az = Име А–Я .color-name-za = Име Я–А - .color-in-drawing = Цветове в този чертеж .color-sort = Сортиране .color-no-drawing-colors = Все още няма цветове в този чертеж .color-books = Цветови каталози diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index a16245e9e..a10b59019 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1348,7 +1348,6 @@ common = .color-number = Číslo barvy .color-name-az = Název A–Z .color-name-za = Název Z–A - .color-in-drawing = Barvy v tomto výkresu .color-sort = Řadit .color-no-drawing-colors = V tomto výkresu zatím nejsou žádné barvy .color-books = Barevné vzorníky diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 8e2199112..7ec27fa3c 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1349,7 +1349,6 @@ common = .color-number = Farbnummer .color-name-az = Name A–Z .color-name-za = Name Z–A - .color-in-drawing = Farben in dieser Zeichnung .color-sort = Sortieren .color-no-drawing-colors = Noch keine Farben in dieser Zeichnung .color-books = Farbbücher diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index cf52e3306..2d739abfd 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1350,7 +1350,6 @@ common = .color-number = Αριθμός χρώματος .color-name-az = Όνομα Α–Ω .color-name-za = Όνομα Ω–Α - .color-in-drawing = Χρώματα σε αυτό το σχέδιο .color-sort = Ταξινόμηση .color-no-drawing-colors = Δεν υπάρχουν ακόμη χρώματα σε αυτό το σχέδιο .color-books = Βιβλία χρωμάτων diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 39c221c99..de4a4cd78 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1349,7 +1349,6 @@ common = .color-number = Color number .color-name-az = Name A–Z .color-name-za = Name Z–A - .color-in-drawing = Colors in this drawing .color-sort = Sort .color-no-drawing-colors = No colors in this drawing yet .color-books = Color Books diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 1459af092..6ccf8988e 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1349,7 +1349,6 @@ common = .color-number = Número de color .color-name-az = Nombre A–Z .color-name-za = Nombre Z–A - .color-in-drawing = Colores de este dibujo .color-sort = Ordenar .color-no-drawing-colors = Aún no hay colores en este dibujo .color-books = Libros de colores diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 24e76f90a..7dff34f02 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1348,7 +1348,6 @@ common = .color-number = Värin numero .color-name-az = Nimi A–Ö .color-name-za = Nimi Ö–A - .color-in-drawing = Tämän piirustuksen värit .color-sort = Lajittele .color-no-drawing-colors = Tässä piirustuksessa ei ole vielä värejä .color-books = Värikirjat diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index b13f28875..6e514a155 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1349,7 +1349,6 @@ common = .color-number = Numéro de couleur .color-name-az = Nom A–Z .color-name-za = Nom Z–A - .color-in-drawing = Couleurs de ce dessin .color-sort = Trier .color-no-drawing-colors = Aucune couleur dans ce dessin pour l'instant .color-books = Carnets de couleurs diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 03b09327c..d5d58eb92 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1337,7 +1337,6 @@ common = .color-number = रंग संख्या .color-name-az = नाम A–Z .color-name-za = नाम Z–A - .color-in-drawing = इस ड्रॉइंग के रंग .color-sort = क्रमबद्ध करें .color-no-drawing-colors = इस ड्रॉइंग में अभी तक कोई रंग नहीं है .color-books = रंग पुस्तकें diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 95533f1eb..ae28ca775 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1348,7 +1348,6 @@ common = .color-number = Színszám .color-name-az = Név A–Z .color-name-za = Név Z–A - .color-in-drawing = Színek ebben a rajzban .color-sort = Rendezés .color-no-drawing-colors = Ebben a rajzban még nincsenek színek .color-books = Színkönyvek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index d20cd6253..d19c64fd4 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1348,7 +1348,6 @@ common = .color-number = Numero colore .color-name-az = Nome A–Z .color-name-za = Nome Z–A - .color-in-drawing = Colori in questo disegno .color-sort = Ordina .color-no-drawing-colors = Ancora nessun colore in questo disegno .color-books = Album colori diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 341ca7b0c..1d3cc642d 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1339,7 +1339,6 @@ common = .color-number = 色番号 .color-name-az = 名前 (昇順) .color-name-za = 名前 (降順) - .color-in-drawing = この図面の色 .color-sort = 並べ替え .color-no-drawing-colors = この図面にはまだ色がありません .color-books = カラー ブック diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 8feda62e0..81b836734 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1348,7 +1348,6 @@ common = .color-number = 색상 번호 .color-name-az = 이름 오름차순 .color-name-za = 이름 내림차순 - .color-in-drawing = 이 도면의 색상 .color-sort = 정렬 .color-no-drawing-colors = 이 도면에는 아직 색상이 없습니다 .color-books = 색상표 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 69971103d..e0a414a24 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1354,7 +1354,6 @@ common = .color-number = ເລກສີ .color-name-az = ຊື່ A–Z .color-name-za = ຊື່ Z–A - .color-in-drawing = ສີໃນແບບແຕ້ມນີ້ .color-sort = ຈັດລຽງ .color-no-drawing-colors = ຍັງບໍ່ມີສີໃນແບບແຕ້ມນີ້ .color-books = ປຶ້ມສີ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 8be90e920..2da6d4c6d 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1347,7 +1347,6 @@ common = .color-number = Kleurnummer .color-name-az = Naam A–Z .color-name-za = Naam Z–A - .color-in-drawing = Kleuren in deze tekening .color-sort = Sorteren .color-no-drawing-colors = Nog geen kleuren in deze tekening .color-books = Kleurboeken diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index b7408ba68..1874e7b4e 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1348,7 +1348,6 @@ common = .color-number = Numer koloru .color-name-az = Nazwa A–Z .color-name-za = Nazwa Z–A - .color-in-drawing = Kolory w tym rysunku .color-sort = Sortuj .color-no-drawing-colors = Brak kolorów w tym rysunku .color-books = Wzorniki kolorów diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 1eb42528f..a879ecaac 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1349,7 +1349,6 @@ common = .color-number = Número da cor .color-name-az = Nome A–Z .color-name-za = Nome Z–A - .color-in-drawing = Cores neste desenho .color-sort = Ordenar .color-no-drawing-colors = Ainda não há cores neste desenho .color-books = Catálogos de cores diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 98ae70a65..bd1b67d28 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1352,7 +1352,6 @@ common = .color-number = Номер цвета .color-name-az = Имя А–Я .color-name-za = Имя Я–А - .color-in-drawing = Цвета в этом чертеже .color-sort = Сортировка .color-no-drawing-colors = В этом чертеже пока нет цветов .color-books = Альбомы цветов diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index ce888b89b..6f8efd839 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1335,7 +1335,6 @@ common = .color-number = Renk numarası .color-name-az = Ad A–Z .color-name-za = Ad Z–A - .color-in-drawing = Bu çizimdeki renkler .color-sort = Sırala .color-no-drawing-colors = Bu çizimde henüz renk yok .color-books = Renk kitapları diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index a0c09867c..797197fdd 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1336,7 +1336,6 @@ common = .color-number = 颜色编号 .color-name-az = 名称 A–Z .color-name-za = 名称 Z–A - .color-in-drawing = 此图形中的颜色 .color-sort = 排序 .color-no-drawing-colors = 此图形中还没有颜色 .color-books = 配色系统 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index fc5989507..b08b84662 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1348,7 +1348,6 @@ common = .color-number = 顏色編號 .color-name-az = 名稱 A–Z .color-name-za = 名稱 Z–A - .color-in-drawing = 此圖面中的顏色 .color-sort = 排序 .color-no-drawing-colors = 此圖面中還沒有顏色 .color-books = 配色系統 diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 80b1bf903..3403cd85c 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4725,7 +4725,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Color number" => Some(("common", "color-number")), "Name A–Z" => Some(("common", "color-name-az")), "Name Z–A" => Some(("common", "color-name-za")), - "Colors in this drawing" => Some(("common", "color-in-drawing")), "Sort" => Some(("common", "color-sort")), "No colors in this drawing yet" => Some(("common", "color-no-drawing-colors")), "Color Books" => Some(("common", "color-books")), diff --git a/src/ui/color_select/document.rs b/src/ui/color_select/document.rs index bbd1d0a88..c257f9c1b 100644 --- a/src/ui/color_select/document.rs +++ b/src/ui/color_select/document.rs @@ -21,16 +21,6 @@ pub struct DocColor { } impl DocColor { - /// The text drawn on the swatch: the index number, `RGB`, or the - /// colour-book colour's own name. - pub fn tag(&self) -> String { - match (&self.name, self.color) { - (Some(_), _) => self.label(), - (None, AcadColor::Index(i)) => i.to_string(), - _ => "RGB".to_string(), - } - } - /// 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 { @@ -225,9 +215,9 @@ mod tests { assert_eq!(colors[0].uses, 3); assert_eq!(colors[0].newest, Handle::new(3)); assert_eq!(colors[3].name, None, "an empty name is no name"); - assert_eq!(colors[2].tag(), "RAL 5015"); - assert_eq!(colors[0].tag(), "1"); - assert_eq!(colors[1].tag(), "RGB"); + assert_eq!(colors[2].label(), "RAL 5015"); + assert_eq!(colors[0].label(), "ACI 1"); + assert_eq!(colors[1].label(), "#39BFFC"); } #[test] diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index cf4090223..f02057a44 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -29,6 +29,10 @@ const BODY_HEIGHT: f32 = 368.0; 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 { @@ -55,6 +59,8 @@ pub enum ColorPickerMsg { PickDocument(usize), DocSort(DocSort), DocView(DocView), + /// Filter the Document tab by name. + DocQuery(String), /// Show another loaded colour book. Book(usize), BookPage(usize), @@ -96,6 +102,7 @@ pub struct ColorPicker { doc: Vec, doc_sort: DocSort, doc_view: DocView, + doc_query: String, /// This drawing's colours picked this session, newest first. recent: Vec, books: Arc>, @@ -123,6 +130,7 @@ impl ColorPicker { doc: Vec::new(), doc_sort: DocSort::default(), doc_view: DocView::default(), + doc_query: String::new(), recent: Vec::new(), books: Arc::default(), book: 0, @@ -295,6 +303,7 @@ impl ColorPicker { } } 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); @@ -386,7 +395,7 @@ impl ColorPicker { (Some(status), _) => status.clone(), (None, Some(name)) => { let (r, g, b) = math::color_rgb(self.pending).unwrap_or_default(); - format!("{name} · RGB {r}, {g}, {b}") + format!("{} · RGB {r}, {g}, {b}", name.replacen('$', " - ", 1)) } (None, None) => math::describe(self.pending), }; @@ -586,16 +595,11 @@ impl ColorPicker { fn document_tab(&self) -> Element<'_, Message> { let header = row![ - text(t!("Colors in this drawing")).size(11), - text(format!("· {}", self.doc.len())) - .size(11) - .style(muted_text), - Space::new().width(Length::Fill), - text(t!("Sort")).size(11).style(muted_text), + search_box(&self.doc_query, |query| ColorPickerMsg::DocQuery(query)), pick_list(Some(self.doc_sort), &DocSort::ALL[..], DocSort::to_string) .on_select(|by| picker_msg(ColorPickerMsg::DocSort(by))) .text_size(12) - .padding([3, 6]), + .padding([6, 8]), row![ self.view_button(crate::ui::icons::GRID, DocView::Grid), self.view_button(crate::ui::icons::LIST, DocView::List), @@ -612,11 +616,23 @@ impl ColorPicker { .spacing(10) .into(); } + // Indices stay those of `doc`, which the pick messages refer to. + let query = self.doc_query.trim().to_lowercase(); + let shown: Vec<(usize, &DocColor)> = self + .doc + .iter() + .enumerate() + .filter(|(_, entry)| query.is_empty() || entry.label().to_lowercase().contains(&query)) + .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 { - self.doc + shown .iter() - .enumerate() - .fold(column![].spacing(2), |list, (i, entry)| { + .fold(column![].spacing(2), |list, &(i, entry)| { let fill = math::color_rgb(entry.color) .map_or(Color::from_rgb8(128, 128, 128), rgb_color); let selected = self.pending == entry.color && self.pending_name == entry.name; @@ -632,7 +648,7 @@ impl ColorPicker { }) .into() } else { - self.doc_grid() + self.doc_grid(&shown) }; column![ header, @@ -646,30 +662,47 @@ impl ColorPicker { .into() } - /// Grid or list toggle for the Document tab. + /// Grid or list toggle for the Document tab: dark on the accent colour + /// when active, like the OK button. fn view_button(&self, icon: &'static [u8], view: DocView) -> Element<'_, Message> { let active = self.doc_view == view; button( - svg(crate::ui::icons::themed_handle(icon)) - .width(14) - .height(14), + 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.base.text + } else { + theme.palette().background.base.text + }), + }), + ) + .center(Length::Fill), ) .on_press(picker_msg(ColorPickerMsg::DocView(view))) - .padding(5) - .style(move |theme: &Theme, status| toggle_style(theme, active, status)) + .width(CONTROL_HEIGHT) + .height(CONTROL_HEIGHT) + .padding(0) + .style(move |theme: &Theme, status| { + if active { + button::primary(theme, status) + } else { + toggle_style(theme, false, status) + } + }) .into() } - fn doc_grid(&self) -> Element<'_, Message> { - self.doc + fn doc_grid<'a>(&self, shown: &[(usize, &'a DocColor)]) -> Element<'a, Message> { + shown .chunks(DOC_COLUMNS) - .enumerate() - .fold(column![].spacing(4), |grid, (r, chunk)| { + .fold(column![].spacing(4), |grid, chunk| { let line = chunk .iter() - .enumerate() - .fold(row![].spacing(4), |line, (c, entry)| { - line.push(self.doc_swatch(r * DOC_COLUMNS + c, entry)) + .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))); @@ -681,19 +714,8 @@ impl ColorPicker { fn doc_swatch<'a>(&self, i: usize, entry: &'a DocColor) -> Element<'a, Message> { let fill = math::color_rgb(entry.color).map_or(Color::from_rgb8(128, 128, 128), rgb_color); let selected = self.pending == entry.color && self.pending_name == entry.name; - button( - container( - text(entry.tag()) - .size(10) - .font(iced::Font::MONOSPACE) - .color(ink(fill)), - ) - .align_y(iced::Bottom) - .padding([1, 4]) - .height(Length::Fill) - .clip(true), - ) - .on_press(picker_msg(ColorPickerMsg::PickDocument(i))) + button(Space::new()) + .on_press(picker_msg(ColorPickerMsg::PickDocument(i))) .padding(0) .width(Length::Fill) .height(30) @@ -720,45 +742,39 @@ impl ColorPicker { .center(Length::Fill) .into(); }; - let label = |label: std::borrow::Cow<'static, str>| text(label).size(11).style(muted_text); + // The book list as wide as the colour bars; the search box and the + // load button over the page strip and the RGB values. let books = Arc::clone(&self.books); let top = row![ - column![ - label(t!("Color book")), - pick_list( - Some(self.book), - (0..self.books.len()).collect::>(), - move |&i| books[i].name.clone(), - ) - .on_select(|i| picker_msg(ColorPickerMsg::Book(i))) - .text_size(12) - .padding([4, 6]) - .width(Length::Fill), - ] - .spacing(4) - .width(Length::Fill), - column![ - label(t!("Find")), - text_input("", &self.book_query) - .on_input(|query| picker_msg(ColorPickerMsg::BookQuery(query))) - .size(12) - .padding([5, 8]) - .font(iced::Font::MONOSPACE) - .width(116), - ] - .spacing(4), - load_button( - svg(crate::ui::icons::themed_handle( - crate::ui::icons::FOLDER_OPEN - )) - .width(15) - .height(15) - .into() + pick_list( + Some(self.book), + (0..self.books.len()).collect::>(), + move |&i| books[i].name.clone(), ) - .padding([6, 7]), + .on_select(|i| picker_msg(ColorPickerMsg::Book(i))) + .text_size(12) + .padding([6, 8]) + .width(Length::Fill), + row![ + search_box(&self.book_query, |query| ColorPickerMsg::BookQuery(query)), + load_button( + container(secondary_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) + .width(SIDE_WIDTH), ] - .spacing(8) - .align_y(iced::Bottom); + .spacing(10) + .align_y(iced::Center); // The page's colours, or the first matches while searching. let selected = self.book_selection(); @@ -829,7 +845,7 @@ impl ColorPicker { .width(9) .height(5) .style(|theme: &Theme, _status| svg::Style { - color: Some(theme.palette().background.base.text), + color: Some(theme.palette().secondary.base.text), }), ) .on_press(picker_msg(ColorPickerMsg::BookPage(page))) @@ -1082,6 +1098,45 @@ fn color_bar<'a>( .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() +} + +/// An icon in the text colour of a secondary button, so it reads on it. +fn secondary_icon<'a>(handle: svg::Handle) -> svg::Svg<'a> { + svg(handle).style(|theme: &Theme, _status| svg::Style { + color: Some(theme.palette().secondary.base.text), + }) +} + /// 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)) diff --git a/src/ui/icons.rs b/src/ui/icons.rs index a37c7ccdd..9897fd9e3 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -82,6 +82,7 @@ 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 SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg"); pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg"); pub static DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg"); From 3006aaa04e7c17ed59325b6d3acc4a36032344b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:51:42 +0000 Subject: [PATCH 22/32] Select Color: rustfmt Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/picker.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index f02057a44..0984fd65b 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -699,11 +699,9 @@ impl ColorPicker { 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.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) @@ -716,11 +714,11 @@ impl ColorPicker { let selected = self.pending == entry.color && self.pending_name == entry.name; button(Space::new()) .on_press(picker_msg(ColorPickerMsg::PickDocument(i))) - .padding(0) - .width(Length::Fill) - .height(30) - .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) - .into() + .padding(0) + .width(Length::Fill) + .height(30) + .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) + .into() } fn books_tab(&self) -> Element<'_, Message> { @@ -758,11 +756,13 @@ impl ColorPicker { row![ search_box(&self.book_query, |query| ColorPickerMsg::BookQuery(query)), load_button( - container(secondary_icon(crate::ui::icons::themed_handle( - crate::ui::icons::FOLDER_OPEN - )) - .width(16) - .height(16)) + container( + secondary_icon(crate::ui::icons::themed_handle( + crate::ui::icons::FOLDER_OPEN + )) + .width(16) + .height(16) + ) .center(Length::Fill) .into() ) From 31e6192729087791bdc752823e4c790d8b3ee767 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:57:53 +0000 Subject: [PATCH 23/32] Select Color: Document search box takes half the width Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/picker.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 0984fd65b..5287522d0 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -594,19 +594,26 @@ impl ColorPicker { } fn document_tab(&self) -> Element<'_, Message> { + // Search on the left half; sorting and the view buttons on the right. let header = row![ - search_box(&self.doc_query, |query| ColorPickerMsg::DocQuery(query)), - 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]), + 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), self.view_button(crate::ui::icons::GRID, DocView::Grid), self.view_button(crate::ui::icons::LIST, DocView::List), ] - .spacing(2), + .spacing(2) + .align_y(iced::Center) + .width(Length::FillPortion(1)), ] - .spacing(6) + .spacing(8) .align_y(iced::Center); if self.doc.is_empty() { return column![ From 322c1906f95ef3c694631cc15b9919b014da6a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:00:37 +0000 Subject: [PATCH 24/32] Select Color: more room between the sort dropdown and the view buttons Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/ui/color_select/picker.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 5287522d0..6882ae6c6 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -606,10 +606,13 @@ impl ColorPicker { .text_size(12) .padding([6, 8]) .width(Length::Fill), - self.view_button(crate::ui::icons::GRID, DocView::Grid), - self.view_button(crate::ui::icons::LIST, DocView::List), + row![ + self.view_button(crate::ui::icons::GRID, DocView::Grid), + self.view_button(crate::ui::icons::LIST, DocView::List), + ] + .spacing(2), ] - .spacing(2) + .spacing(8) .align_y(iced::Center) .width(Length::FillPortion(1)), ] From 666234f69ed3a34c57be8343f828f100bf68422e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:51:02 +0000 Subject: [PATCH 25/32] Select Color: colour book folders, app-style view toggles, load button by the book list - Colour books now come from folders, like AutoCAD's Color Book Locations: Options > Files lists them (add, open, remove); by default one color_books folder in the config directory. The Color Books tab shows the built-in books plus every .acb in those folders; Load copies the chosen file into the first folder. Web: session only, as before. - Grid/list buttons use the app's toggle style (light accent, its text colour for the icon, same corners as inactive). - Color Books: the load button sits right of the book list; the search box keeps its start over the page strip and runs to the end. - Translations for the two new Options strings in all 22 locales. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- locales/ar-SA/opencadstudio.ftl | 2 + locales/bg-BG/opencadstudio.ftl | 2 + locales/cs-CZ/opencadstudio.ftl | 2 + locales/de-DE/opencadstudio.ftl | 2 + locales/el-GR/opencadstudio.ftl | 2 + locales/en-US/opencadstudio.ftl | 2 + locales/es-ES/opencadstudio.ftl | 2 + locales/fi-FI/opencadstudio.ftl | 2 + locales/fr-FR/opencadstudio.ftl | 2 + locales/hi-IN/opencadstudio.ftl | 2 + locales/hu-HU/opencadstudio.ftl | 2 + locales/it-IT/opencadstudio.ftl | 2 + locales/ja-JP/opencadstudio.ftl | 2 + locales/ko-KR/opencadstudio.ftl | 2 + locales/lo-LA/opencadstudio.ftl | 2 + locales/nl-NL/opencadstudio.ftl | 2 + locales/pl-PL/opencadstudio.ftl | 2 + locales/pt-BR/opencadstudio.ftl | 2 + locales/ru-RU/opencadstudio.ftl | 2 + locales/tr-TR/opencadstudio.ftl | 2 + locales/zh-CN/opencadstudio.ftl | 2 + locales/zh-TW/opencadstudio.ftl | 2 + src/app/mod.rs | 16 +++-- src/app/settings.rs | 18 ++++-- src/app/update/file.rs | 7 ++- src/app/update/mod.rs | 107 +++++++++++++++++++++++--------- src/app/view/modal.rs | 1 + src/locale_catalog.rs | 2 + src/ui/color_select/picker.rs | 43 +++++++------ src/ui/window/options.rs | 45 ++++++++++++++ 30 files changed, 222 insertions(+), 61 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 643e7dab9..c601eb213 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1372,6 +1372,8 @@ common = .color-not-a-book = ليس كتاب ألوان .color-book-encrypted = كتاب الألوان هذا مشفر ولا يمكن قراءته .color-book-empty = لا يحتوي كتاب الألوان على ألوان + .color-book-locations = مواقع كتب الألوان + .color-book-locations-hint = المجلدات التي يُبحث فيها عن كتب الألوان ‎.acb. يُنسخ الكتاب الذي يُحمَّل في اختيار اللون إلى المجلد الأول. .cad-color-index-aci-10-249 = فهرس ألوان ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = الرسم متساوي القياس diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 8bc2bce14..ed8c27b53 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .color-not-a-book = Не е цветови каталог .color-book-encrypted = Този цветови каталог е шифрован и не може да бъде прочетен .color-book-empty = Цветовият каталог няма цветове + .color-book-locations = Местоположения на цветови каталози + .color-book-locations-hint = Папки, в които се търсят цветови каталози .acb. Каталог, зареден в „Избор на цвят“, се копира в първата папка. .cad-color-index-aci-10-249 = ACI Цветови индекс 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Изометрично чертане diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index a10b59019..51e95ebef 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .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ů + .color-book-locations-hint = Složky, ve kterých se hledají barevné vzorníky .acb. Vzorník načtený ve Výběru barvy se zkopíruje do první složky. .cad-color-index-aci-10-249 = Barevný index ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Izometrické kreslení diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 7ec27fa3c..03da184fe 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1360,6 +1360,8 @@ common = .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 + .color-book-locations-hint = Ordner, in denen nach .acb-Farbbüchern gesucht wird. Ein in „Farbe wählen“ geladenes Farbbuch wird in den ersten Ordner kopiert. .cad-color-index-aci-10-249 = ACI Farbindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrisches Zeichnen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 2d739abfd..26ac610cd 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1361,6 +1361,8 @@ common = .color-not-a-book = Δεν είναι βιβλίο χρωμάτων .color-book-encrypted = Αυτό το βιβλίο χρωμάτων είναι κρυπτογραφημένο και δεν μπορεί να διαβαστεί .color-book-empty = Το βιβλίο χρωμάτων δεν έχει χρώματα + .color-book-locations = Θέσεις βιβλίων χρωμάτων + .color-book-locations-hint = Φάκελοι στους οποίους αναζητούνται βιβλία χρωμάτων .acb. Ένα βιβλίο που φορτώνεται στην Επιλογή χρώματος αντιγράφεται στον πρώτο φάκελο. .cad-color-index-aci-10-249 = Δείκτης χρώματος ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Ισομετρική σχεδίαση diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index de4a4cd78..ee3cb8fc8 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1360,6 +1360,8 @@ common = .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 + .color-book-locations-hint = Folders searched for .acb color books. A book loaded in Select Color is copied into the first folder. .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .grayscale-250-255 = Grayscale 250–255 .isometric-drafting = Isometric drafting diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 6ccf8988e..233b63454 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1360,6 +1360,8 @@ common = .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 + .color-book-locations-hint = Carpetas en las que se buscan libros de colores .acb. Un libro cargado en Seleccionar color se copia en la primera carpeta. .cad-color-index-aci-10-249 = Índice de color ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Dibujo isométrico diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 7dff34f02..0d856e192 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .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 + .color-book-locations-hint = Kansiot, joista etsitään .acb-värikirjoja. Värin valinnassa ladattu värikirja kopioidaan ensimmäiseen kansioon. .cad-color-index-aci-10-249 = ACI väriindeksi 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrinen piirtäminen diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 6e514a155..de4e2a87e 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1360,6 +1360,8 @@ common = .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 + .color-book-locations-hint = Dossiers où rechercher les carnets de couleurs .acb. Un carnet chargé dans Sélectionner une couleur est copié dans le premier dossier. .cad-color-index-aci-10-249 = ACI Indice de couleur 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Dessin isométrique diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index d5d58eb92..5745a31fe 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1348,6 +1348,8 @@ common = .color-not-a-book = यह रंग पुस्तक नहीं है .color-book-encrypted = यह रंग पुस्तक एन्क्रिप्टेड है और पढ़ी नहीं जा सकती .color-book-empty = रंग पुस्तक में कोई रंग नहीं है + .color-book-locations = रंग पुस्तकों के स्थान + .color-book-locations-hint = वे फ़ोल्डर जिनमें .acb रंग पुस्तकें खोजी जाती हैं। रंग चुनें में लोड की गई पुस्तक पहले फ़ोल्डर में कॉपी की जाती है। .cad-color-index-aci-10-249 = ACI रंग सूचकांक 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = सममितीय आरेखन diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index ae28ca775..cf22bb3fb 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .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 + .color-book-locations-hint = Mappák, amelyekben a program .acb színkönyveket keres. A Szín kiválasztása ablakban betöltött színkönyv az első mappába másolódik. .cad-color-index-aci-10-249 = ACI színindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Izometrikus rajzolás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index d19c64fd4..77bc17df5 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .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 + .color-book-locations-hint = Cartelle in cui cercare gli album colori .acb. Un album caricato in Seleziona colore viene copiato nella prima cartella. .cad-color-index-aci-10-249 = ACI Indice colori 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Disegno isometrico diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 1d3cc642d..c16fa3b08 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1350,6 +1350,8 @@ common = .color-not-a-book = カラー ブックではありません .color-book-encrypted = このカラー ブックは暗号化されているため読み込めません .color-book-empty = カラー ブックに色がありません + .color-book-locations = カラー ブックの場所 + .color-book-locations-hint = .acb カラー ブックを検索するフォルダです。[色を選択] で読み込んだブックは最初のフォルダにコピーされます。 .cad-color-index-aci-10-249 = ACI カラーインデックス 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = アイソメ図作成 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 81b836734..1bbaea5c3 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .color-not-a-book = 색상표가 아닙니다 .color-book-encrypted = 이 색상표는 암호화되어 있어 읽을 수 없습니다 .color-book-empty = 색상표에 색상이 없습니다 + .color-book-locations = 색상표 위치 + .color-book-locations-hint = .acb 색상표를 찾는 폴더입니다. 색상 선택에서 로드한 색상표는 첫 번째 폴더에 복사됩니다. .cad-color-index-aci-10-249 = ACI 컬러 인덱스 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 등각 제도 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index e0a414a24..f8527bb9f 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1365,6 +1365,8 @@ common = .color-not-a-book = ບໍ່ແມ່ນປຶ້ມສີ .color-book-encrypted = ປຶ້ມສີນີ້ຖືກເຂົ້າລະຫັດ ແລະອ່ານບໍ່ໄດ້ .color-book-empty = ປຶ້ມສີບໍ່ມີສີ + .color-book-locations = ທີ່ຕັ້ງປຶ້ມສີ + .color-book-locations-hint = ໂຟນເດີທີ່ໃຊ້ຊອກຫາປຶ້ມສີ .acb. ປຶ້ມທີ່ໂຫຼດໃນເລືອກສີຈະຖືກສຳເນົາໄປໃສ່ໂຟນເດີທຳອິດ. .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .grayscale-250-255 = ລະດັບສີເທົາ 250–255 .isometric-drafting = ການຂຽນແບບໄອໂຊເມຕຣິກ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 2da6d4c6d..730ea01da 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1358,6 +1358,8 @@ common = .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 + .color-book-locations-hint = Mappen waarin naar .acb-kleurboeken wordt gezocht. Een kleurboek dat in Kleur selecteren wordt geladen, wordt naar de eerste map gekopieerd. .cad-color-index-aci-10-249 = ACI Kleurindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrisch tekenen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 1874e7b4e..92554bcf4 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .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 + .color-book-locations-hint = Foldery przeszukiwane w poszukiwaniu wzorników kolorów .acb. Wzornik wczytany w oknie Wybierz kolor jest kopiowany do pierwszego folderu. .cad-color-index-aci-10-249 = Indeks kolorów ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Rysowanie izometryczne diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index a879ecaac..e31741d24 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1360,6 +1360,8 @@ common = .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 + .color-book-locations-hint = Pastas pesquisadas em busca de catálogos de cores .acb. Um catálogo carregado em Selecionar cor é copiado para a primeira pasta. .cad-color-index-aci-10-249 = Índice de cor ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Desenho isométrico diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index bd1b67d28..e00da7b3c 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1363,6 +1363,8 @@ common = .color-not-a-book = Это не альбом цветов .color-book-encrypted = Этот альбом цветов зашифрован и не может быть прочитан .color-book-empty = В альбоме цветов нет цветов + .color-book-locations = Расположение альбомов цветов + .color-book-locations-hint = Папки, в которых ищутся альбомы цветов .acb. Альбом, загруженный в окне «Выбор цвета», копируется в первую папку. .cad-color-index-aci-10-249 = Индекс цвета ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Изометрическое черчение diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 6f8efd839..37fbeb6ca 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1346,6 +1346,8 @@ common = .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ı + .color-book-locations-hint = Bu klasörlerde .acb renk kitapları aranır. Renk Seç'te yüklenen bir kitap ilk klasöre kopyalanır. .cad-color-index-aci-10-249 = ACI renk dizini 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = İzometrik çizim diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 797197fdd..85e50033a 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1347,6 +1347,8 @@ common = .color-not-a-book = 不是配色系统 .color-book-encrypted = 此配色系统已加密,无法读取 .color-book-empty = 配色系统中没有颜色 + .color-book-locations = 配色系统位置 + .color-book-locations-hint = 搜索 .acb 配色系统的文件夹。在“选择颜色”中加载的配色系统会复制到第一个文件夹。 .cad-color-index-aci-10-249 = ACI 颜色索引 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 等轴测绘图 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index b08b84662..2d89f57eb 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1359,6 +1359,8 @@ common = .color-not-a-book = 不是配色系統 .color-book-encrypted = 此配色系統已加密,無法讀取 .color-book-empty = 配色系統中沒有顏色 + .color-book-locations = 配色系統位置 + .color-book-locations-hint = 搜尋 .acb 配色系統的資料夾。在「選取顏色」中載入的配色系統會複製到第一個資料夾。 .cad-color-index-aci-10-249 = ACI 顏色索引 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 等角製圖 diff --git a/src/app/mod.rs b/src/app/mod.rs index 3906e3dc1..e8435b03d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -965,9 +965,10 @@ pub(super) struct OpenCADStudio { // ── Floating panel windows ──────────────────────────────────────────── /// The open Select Color panel, if any. color_picker: Option, - /// Colour books loaded this session, read lazily from `color_book_paths`. + /// The built-in colour books and those in `color_book_dirs`, read + /// lazily when the Select Color panel opens. color_books: std::sync::Arc>, - color_book_paths: Vec, + color_book_dirs: Vec, color_books_read: bool, /// Recently committed real colours, newest first. /// ACI and True Color remain semantically distinct even when their RGB matches. @@ -4174,8 +4175,13 @@ pub enum Message { /// Commit the colour chosen in the shared picker, with its `BOOK$NAME` /// when it comes from a colour book. ColorWindowPick(codec::types::Color, Option), - /// An `.acb` file was read: the book and, on desktop, where it lives. - ColorBookLoaded(Result<(crate::ui::color_select::book::ColorBook, Option), String>), + /// An `.acb` file was read (and, on desktop, copied into the first + /// colour book folder). + ColorBookLoaded(Result), + /// Options › Files: pick a folder to search for colour books. + ColorBookDirBrowse, + ColorBookDirAdd(String), + ColorBookDirRemove(usize), /// Set a block/linetype Handle field on the selected dim style from a /// dropdown of available block-records / linetypes (by name). DsSetHandle { @@ -4608,7 +4614,7 @@ impl OpenCADStudio { web_autosave_clear_after: Vec::new(), color_picker: None, color_books: Default::default(), - color_book_paths: Vec::new(), + color_book_dirs: Vec::new(), color_books_read: false, active_modal: None, hyperlink_editor_handles: Vec::new(), diff --git a/src/app/settings.rs b/src/app/settings.rs index 2a3795ce2..a70db4532 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -619,9 +619,10 @@ pub struct UserSettings { /// Blocks palette: library folders or drawings, last used first. #[serde(default)] pub block_libraries: Vec, - /// Colour book (.acb) files loaded in the Select Color panel. - #[serde(default)] - pub color_books: Vec, + /// Folders searched for `.acb` colour books. Books loaded in the Select + /// Color panel are copied into the first one. + #[serde(default = "default_color_book_dirs")] + pub color_book_dirs: Vec, /// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons, /// 4 details, 5 list. #[serde(default = "default_block_view")] @@ -749,6 +750,15 @@ fn default_grid_major() -> u32 { 5 } +/// One folder in the config directory; none on the web, which has no folders. +fn default_color_book_dirs() -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if let Some(dir) = crate::config::config_dir() { + return vec![dir.join("color_books").display().to_string()]; + } + Vec::new() +} + fn default_true() -> bool { true } @@ -904,7 +914,7 @@ impl Default for UserSettings { block_recent: Vec::new(), block_favorites: Vec::new(), block_libraries: Vec::new(), - color_books: Vec::new(), + color_book_dirs: default_color_book_dirs(), block_palette_view: 1, block_insert: BlockInsertOptions::default(), block_mru_list: 50, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 082199c2a..a38f941e7 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -860,7 +860,7 @@ impl OpenCADStudio { block_recent: self.block_palette.recent.clone(), block_favorites: self.block_palette.favorites.clone(), block_libraries: self.block_palette.libraries.clone(), - color_books: self.color_book_paths.clone(), + color_book_dirs: self.color_book_dirs.clone(), block_palette_view: self.block_palette.view as u8, block_insert: self.block_palette.options.clone(), block_mru_list: self.block_mru_list, @@ -1026,7 +1026,10 @@ impl OpenCADStudio { self.block_palette.recent = s.block_recent.clone(); self.block_palette.favorites = s.block_favorites.clone(); self.block_palette.libraries = s.block_libraries.clone(); - self.color_book_paths = s.color_books.clone(); + if self.color_book_dirs != s.color_book_dirs { + self.color_book_dirs = s.color_book_dirs.clone(); + self.color_books_read = false; + } self.block_palette.view = crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view); self.block_palette.set_options(s.block_insert.clone()); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 01630caea..8e8f1fda0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11596,25 +11596,38 @@ impl OpenCADStudio { Some(crate::ui::color_select::Outcome::Commit(color, name)) => { self.update(Message::ColorWindowPick(color, name)) } - Some(crate::ui::color_select::Outcome::LoadBook) => Task::perform( - async { - let file = crate::sys::file_dialog() - .add_filter(crate::t!("Color book").as_ref(), &["acb", "ACB"]) - .pick_file() - .await - .ok_or_else(|| "Cancelled".to_string())?; - let book = crate::ui::color_select::book::parse( - &String::from_utf8_lossy(&file.read().await), - )?; - // The web build has no paths to read again later. - #[cfg(not(target_arch = "wasm32"))] - let path = Some(crate::sys::handle_path(&file).display().to_string()); - #[cfg(target_arch = "wasm32")] - let path = None; - Ok((book, path)) - }, - Message::ColorBookLoaded, - ), + Some(crate::ui::color_select::Outcome::LoadBook) => { + // Copied into the first colour book folder so it stays + // when the original moves; the web keeps it for the + // session only. + let folder = self.color_book_dirs.first().cloned(); + Task::perform( + async move { + 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 = crate::ui::color_select::book::parse( + &String::from_utf8_lossy(&bytes), + )?; + #[cfg(not(target_arch = "wasm32"))] + if let Some(folder) = folder { + let folder = std::path::PathBuf::from(folder); + std::fs::create_dir_all(&folder) + .and_then(|()| { + std::fs::write(folder.join(file.file_name()), &bytes) + }) + .map_err(|e| e.to_string())?; + } + #[cfg(target_arch = "wasm32")] + let _ = folder; + Ok(book) + }, + Message::ColorBookLoaded, + ) + } Some(crate::ui::color_select::Outcome::Cancel) => { self.color_picker = None; Task::none() @@ -11651,7 +11664,7 @@ impl OpenCADStudio { } self.on_color_window_pick(color, name) } - Message::ColorBookLoaded(Ok((book, path))) => { + Message::ColorBookLoaded(Ok(book)) => { // A book loaded again replaces the earlier copy. let books = std::sync::Arc::make_mut(&mut self.color_books); let index = match books.iter().position(|b| b.name == book.name) { @@ -11664,10 +11677,6 @@ impl OpenCADStudio { books.len() - 1 } }; - if let Some(path) = path.filter(|path| !self.color_book_paths.contains(path)) { - self.color_book_paths.push(path); - self.save_config(); - } if let Some(picker) = self.color_picker.as_mut() { picker.show_book(std::sync::Arc::clone(&self.color_books), index); } @@ -11679,6 +11688,35 @@ impl OpenCADStudio { } Task::none() } + #[cfg(not(target_arch = "wasm32"))] + Message::ColorBookDirBrowse => Task::perform( + async { + crate::sys::file_dialog() + .pick_folder() + .await + .map(|handle| crate::sys::handle_path(&handle).display().to_string()) + }, + |dir| dir.map_or(Message::Noop, Message::ColorBookDirAdd), + ), + // Browsers expose no folder picker. + #[cfg(target_arch = "wasm32")] + Message::ColorBookDirBrowse => Task::none(), + Message::ColorBookDirAdd(dir) => { + if !self.color_book_dirs.contains(&dir) { + self.color_book_dirs.push(dir); + self.color_books_read = false; + self.persist_settings_if_changed(); + } + Task::none() + } + Message::ColorBookDirRemove(i) => { + if i < self.color_book_dirs.len() { + self.color_book_dirs.remove(i); + self.color_books_read = false; + self.persist_settings_if_changed(); + } + Task::none() + } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } } @@ -11759,9 +11797,9 @@ impl OpenCADStudio { document.layers.get(layer).map(|layer| layer.color) } - /// Read the built-in colour books and the ones remembered in the - /// settings, once per session. A loaded book replaces a built-in one of - /// the same name. + /// Read the built-in colour books and every `.acb` in the colour book + /// folders, once until the folders change. A book in a 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; @@ -11771,8 +11809,21 @@ impl OpenCADStudio { let mut books: Vec<_> = book::builtin().collect(); #[cfg(not(target_arch = "wasm32"))] for loaded in self - .color_book_paths + .color_book_dirs .iter() + .flat_map(|dir| { + 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(); + files + }) .filter_map(|path| std::fs::read_to_string(path).ok()) .filter_map(|text| book::parse(&text).ok()) { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 348c4bd97..57a88e393 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -367,6 +367,7 @@ impl OpenCADStudio { } }, &self.qnew_template, + &self.color_book_dirs, self.double_click_block_refedit, self.double_click_block_attedit, self.cursor_type, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 3403cd85c..9ce668e84 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4736,6 +4736,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "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")), + "Folders searched for .acb color books. A book loaded in Select Color is copied into the first folder." => Some(("common", "color-book-locations-hint")), "CAD Color Index (ACI) 10–249" => Some(("common", "cad-color-index-aci-10-249")), "Grayscale 250–255" => Some(("common", "grayscale-250-255")), "Isometric drafting" => Some(("common", "isometric-drafting")), diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 6882ae6c6..157c139c0 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -672,8 +672,9 @@ impl ColorPicker { .into() } - /// Grid or list toggle for the Document tab: dark on the accent colour - /// when active, like the OK button. + /// 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( @@ -683,7 +684,7 @@ impl ColorPicker { .height(18) .style(move |theme: &Theme, _status| svg::Style { color: Some(if active { - theme.palette().primary.base.text + theme.palette().primary.weak.text } else { theme.palette().background.base.text }), @@ -695,13 +696,7 @@ impl ColorPicker { .width(CONTROL_HEIGHT) .height(CONTROL_HEIGHT) .padding(0) - .style(move |theme: &Theme, status| { - if active { - button::primary(theme, status) - } else { - toggle_style(theme, false, status) - } - }) + .style(move |theme: &Theme, status| toggle_style(theme, active, status)) .into() } @@ -750,21 +745,20 @@ impl ColorPicker { .center(Length::Fill) .into(); }; - // The book list as wide as the colour bars; the search box and the - // load button over the page strip and the RGB values. + // 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![ - 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), row![ - search_box(&self.book_query, |query| ColorPickerMsg::BookQuery(query)), + 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( secondary_icon(crate::ui::icons::themed_handle( @@ -781,6 +775,11 @@ impl ColorPicker { .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) diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 5b1ea49f9..e91aabe35 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -204,6 +204,7 @@ pub fn view_window<'a>( drawing_prefs: DrawingPrefs, folders: Folders, qnew_template: &'a str, + color_book_dirs: &'a [String], double_click_block_refedit: bool, double_click_block_attedit: bool, cursor_type: CursorType, @@ -1697,6 +1698,49 @@ pub fn view_window<'a>( .align_y(iced::Center) }; + // Searched for `.acb` colour books; editable, unlike the folders above. + // Browsers have no folders, so the web build leaves it out. + let color_book_locations = |dirs: &'a [String]| -> Element<'a, Message> { + if cfg!(target_arch = "wasm32") { + return Space::new().into(); + } + let list = dirs + .iter() + .enumerate() + .fold(column![].spacing(6), |list, (i, dir)| { + list.push( + row![ + text(dir.as_str()).size(11).width(Fill), + button(text(crate::t!("Open folder")).size(11)) + .padding([4, 10]) + .style(button::secondary) + .on_press(Message::OpenFolder(dir.clone())), + button(text(crate::t!("Remove")).size(11)) + .padding([4, 10]) + .style(button::secondary) + .on_press(Message::ColorBookDirRemove(i)), + ] + .spacing(10) + .align_y(iced::Center), + ) + }); + column![ + text(crate::t!("Color book locations")).size(12), + text(crate::t!( + "Folders searched for .acb color books. A book loaded in Select Color is copied into the first folder." + )) + .size(11), + list, + button(text(crate::t!("Add folder…")).size(11)) + .padding([4, 10]) + .style(button::secondary) + .on_press(Message::ColorBookDirBrowse), + Space::new().height(16), + ] + .spacing(6) + .into() + }; + let files = column![ text(crate::t!("Files")).size(15), Space::new().height(6), @@ -1716,6 +1760,7 @@ pub fn view_window<'a>( Space::new().height(12), folder_row(crate::t!("Fonts"), folders.fonts.clone()), Space::new().height(16), + color_book_locations(color_book_dirs), text(crate::t!("Default template for new drawings")).size(12), Space::new().height(4), row![ From a1cae559558bc3e529777565bfdc199e08cd01e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:10:33 +0000 Subject: [PATCH 26/32] Select Color: ship only RAL CLASSIC as a built-in colour book SIKKENS 4041 is no longer built in; it can still be loaded from an .acb file in a colour book folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- assets/color_books/README.md | 5 +- assets/color_books/sikkens-4041.acb | 2101 --------------------------- src/ui/color_select/book.rs | 11 +- 3 files changed, 5 insertions(+), 2112 deletions(-) delete mode 100644 assets/color_books/sikkens-4041.acb diff --git a/assets/color_books/README.md b/assets/color_books/README.md index 5b4198ea6..334a4a632 100644 --- a/assets/color_books/README.md +++ b/assets/color_books/README.md @@ -6,8 +6,7 @@ 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 | -| `sikkens-4041.acb` | SIKKENS 4041, 1,624 colours | Open Colour Systems Collection 2.0, © 2015 dtp studio oldenburg (freieFarbe e.V.); CC BY-ND 4.0. Converted from the GIMP palette: same names, values and order, only the file format changed | Values are approximate screen representations, not colour-binding -references. RAL is a registered trademark of RAL gGmbH and Sikkens of -AkzoNobel; these files are not affiliated with or endorsed by either. +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/sikkens-4041.acb b/assets/color_books/sikkens-4041.acb deleted file mode 100644 index d1aba92aa..000000000 --- a/assets/color_books/sikkens-4041.acb +++ /dev/null @@ -1,2101 +0,0 @@ - - - - SIKKENS 4041 - 1 - 0 - - Sikkens ON.00.90241239236 - Sikkens ON.00.81221221218 - Sikkens ON.00.69192191189 - Sikkens ON.00.60165163162 - Sikkens ON.00.50135135134 - Sikkens ON.00.40108107106 - Sikkens ON.00.31888686 - - - Sikkens ON.00.21686665 - Sikkens ON.00.86235234230 - Sikkens ON.00.76207206205 - Sikkens ON.00.64179177175 - Sikkens ON.00.55150150148 - Sikkens ON.00.45125124124 - Sikkens ON.00.3610099100 - - - Sikkens ON.00.26777676 - Sikkens ON.00.15575657 - Sikkens ON.00.83228229224 - Sikkens ON.00.78214214211 - Sikkens ON.00.63176174170 - Sikkens ON.00.58160159160 - Sikkens ON.00.53146146146 - - - Sikkens ON.00.33949493 - Sikkens ON.01.26808182 - Sikkens ON.00.10454443 - Sikkens AN.02.82231221221 - Sikkens AN.01.80223216214 - Sikkens AN.02.80230218217 - Sikkens AN.02.77222208209 - - - Sikkens AN.02.65184174174 - Sikkens AN.02.55157148149 - Sikkens AN.02.3510599100 - Sikkens AN.02.25827879 - Sikkens A0.05.85247223224 - Sikkens A0.05.75221197201 - Sikkens A0.05.65194173176 - - - Sikkens A0.05.55163143148 - Sikkens A0.05.45134119122 - Sikkens A0.05.3511397101 - Sikkens A0.05.25867476 - Sikkens A0.05.15665858 - Sikkens A0.07.77237204212 - Sikkens A0.10.75245195206 - - - Sikkens A0.10.70227181194 - Sikkens A0.10.60194155166 - Sikkens A0.10.50163129139 - Sikkens A0.10.40133105113 - Sikkens A0.10.301057886 - Sikkens A0.10.20826266 - Sikkens A0.15.65225162177 - - - Sikkens A0.20.60215140162 - Sikkens A0.20.50190123143 - Sikkens A0.20.4015495111 - Sikkens A0.20.301257592 - Sikkens A0.20.20965768 - Sikkens A0.20.12724353 - Sikkens A6.25.05694548 - - - Sikkens A8.20.60218143151 - Sikkens A6.30.50201104122 - Sikkens A0.30.50204104138 - Sikkens A6.35.4018279107 - Sikkens A0.30.4016883111 - Sikkens A0.30.301376693 - Sikkens A0.41.281494384 - - - Sikkens A0.30.201094669 - Sikkens B3.19.67239158159 - Sikkens B2.30.60227126132 - Sikkens B3.37.51222100111 - Sikkens B2.40.5021793105 - Sikkens B0.39.401876990 - Sikkens B0.39.301515674 - - - Sikkens B0.35.251265163 - Sikkens B0.25.251186472 - Sikkens B3.31.54228116124 - Sikkens B6.40.5021795100 - Sikkens B6.40.401837481 - Sikkens B6.50.401925874 - Sikkens B2.40.401856687 - - - Sikkens B3.49.331714767 - Sikkens B8.50.201243541 - Sikkens B6.45.151053946 - Sikkens B3.14.73247183182 - Sikkens B3.25.60233139144 - Sikkens B2.30.50210104116 - Sikkens B2.30.401678594 - - - Sikkens B0.35.351626884 - Sikkens B2.30.301366877 - Sikkens B2.24.251176470 - Sikkens B2.30.201064653 - Sikkens B5.08.80248204200 - Sikkens B2.20.70245166168 - Sikkens B2.20.60215144146 - - - Sikkens B2.20.50193120125 - Sikkens B2.20.401509599 - Sikkens B2.20.301186773 - Sikkens B2.20.20975961 - Sikkens B1.16.12694547 - Sikkens B7.06.83251216211 - Sikkens B6.20.70243170167 - - - Sikkens B6.30.60232131128 - Sikkens B6.30.50202107110 - Sikkens B6.35.401698083 - Sikkens B6.30.401668485 - Sikkens B6.30.301356870 - Sikkens B6.28.19995046 - Sikkens B3.07.78240203202 - - - Sikkens B6.10.70229184180 - Sikkens B6.15.65219159155 - Sikkens B6.20.60216143140 - Sikkens B6.20.50184120116 - Sikkens B6.20.401489796 - Sikkens B6.20.301206869 - Sikkens B7.28.191004750 - - - Sikkens B2.10.80248206205 - Sikkens B2.10.70221176175 - Sikkens B2.10.60197156157 - Sikkens B2.10.50162125126 - Sikkens B2.10.40134103106 - Sikkens B2.10.301088384 - Sikkens B2.10.25946870 - - - Sikkens B2.10.20846263 - Sikkens B6.05.85249227223 - Sikkens B2.05.75229202203 - Sikkens B2.05.70208185184 - Sikkens B2.05.65199175177 - Sikkens B2.05.55167150148 - Sikkens B1.05.45139122122 - - - Sikkens B3.05.40124110109 - Sikkens B1.05.351139899 - Sikkens CN.02.88249243236 - Sikkens CN.03.86248232226 - Sikkens CN.02.77220208203 - Sikkens CN.02.67193184181 - Sikkens CN.02.57162155153 - - - Sikkens CN.02.47136128127 - Sikkens CN.02.37111105104 - Sikkens CN.02.27858079 - Sikkens C0.04.83247224220 - Sikkens C0.04.78232212208 - Sikkens C0.05.75220199196 - Sikkens C0.05.65194173171 - - - Sikkens C0.05.55166146143 - Sikkens C0.05.45136121119 - Sikkens C0.05.35116101100 - Sikkens C0.05.25867573 - Sikkens C0.07.82250218211 - Sikkens C0.07.77236205200 - Sikkens C0.10.70223182176 - - - Sikkens C0.10.60195158153 - Sikkens C0.10.50161130128 - Sikkens C0.10.40137108104 - Sikkens C0.10.301088683 - Sikkens C0.10.20816564 - Sikkens C0.10.80248205197 - Sikkens C0.15.75247187180 - - - Sikkens C0.15.65223165159 - Sikkens C0.15.55188135131 - Sikkens C0.20.50185122115 - Sikkens C0.20.4015210094 - Sikkens C0.20.301217470 - Sikkens C0.30.201004845 - Sikkens C0.08.80249211202 - - - Sikkens C4.20.70238171155 - Sikkens C4.20.60205146133 - Sikkens C4.20.50177123113 - Sikkens C4.30.50193116100 - Sikkens C0.30.401578976 - Sikkens C0.30.301327364 - Sikkens C0.40.201154841 - - - Sikkens C1.10.79249201188 - Sikkens C0.20.70248173160 - Sikkens C0.20.60212144137 - Sikkens C0.25.55201124119 - Sikkens C0.25.4517110297 - Sikkens C0.30.401678579 - Sikkens C0.30.301255955 - - - Sikkens C4.35.15994642 - Sikkens C0.17.72246173161 - Sikkens C0.25.65238151143 - Sikkens C0.30.60235132126 - Sikkens C0.30.50207115107 - Sikkens B8.35.451949690 - Sikkens C0.40.401858482 - - - Sikkens C0.40.301466362 - Sikkens C0.40.201103937 - Sikkens C3.20.73255180163 - Sikkens C0.28.58226132121 - Sikkens C0.40.5021210090 - Sikkens C0.50.401905958 - Sikkens C0.50.301554750 - - - Sikkens C2.60.251413738 - Sikkens C2.50.251253936 - Sikkens C0.45.201134040 - Sikkens C4.30.60228136117 - Sikkens C4.40.5020710180 - Sikkens C4.40.401788367 - Sikkens C4.50.401867057 - - - Sikkens C3.57.311665048 - Sikkens C4.40.301516150 - Sikkens C0.60.301683344 - Sikkens C0.48.201273340 - Sikkens C2.24.65237145131 - Sikkens C2.38.5021610792 - Sikkens C2.53.401956762 - - - Sikkens C4.60.402025649 - Sikkens C6.65.351764538 - Sikkens C4.75.301813334 - Sikkens C4.60.301664338 - Sikkens C4.50.301515346 - Sikkens C4.32.60235136117 - Sikkens C5.43.5824612498 - - - Sikkens C5.43.5423111597 - Sikkens C4.46.502219880 - Sikkens C4.50.502218978 - Sikkens C4.55.401965836 - Sikkens C4.49.331646958 - Sikkens C4.55.301494739 - Sikkens C8.34.63245146112 - - - Sikkens C8.45.5523511683 - Sikkens C8.50.5021910275 - Sikkens C6.49.482078761 - Sikkens C8.50.401848154 - Sikkens C8.51.391767849 - Sikkens C8.60.301575643 - Sikkens C8.50.301436649 - - - Sikkens C7.26.67247166141 - Sikkens C8.30.60224141112 - Sikkens C8.40.6023912896 - Sikkens C8.40.5021110976 - Sikkens C8.40.401739170 - Sikkens C8.40.301346546 - Sikkens C7.34.241156757 - - - Sikkens C8.40.201095848 - Sikkens C7.22.72249175152 - Sikkens C8.25.65234156132 - Sikkens C8.25.55200133111 - Sikkens C8.30.5019111592 - Sikkens C8.25.4517111293 - Sikkens C8.30.401629777 - - - Sikkens C8.30.301317862 - Sikkens C8.30.201005745 - Sikkens C8.15.75243191172 - Sikkens C8.19.74255189165 - Sikkens C8.20.70244179157 - Sikkens C8.20.60219150130 - Sikkens C8.20.4014610185 - - - Sikkens C8.20.301117157 - Sikkens C8.20.20916155 - Sikkens C4.05.20746461 - Sikkens C8.07.82247217208 - Sikkens C8.10.80253213200 - Sikkens C8.10.70219185172 - Sikkens C8.15.65216166152 - - - Sikkens C8.20.50174126109 - Sikkens C4.20.401449887 - Sikkens C4.20.301167768 - Sikkens C6.22.231006557 - Sikkens CN.01.90250241235 - Sikkens C8.04.83242226219 - Sikkens C8.05.75220199191 - - - Sikkens C8.10.60190159149 - Sikkens C8.15.55184144131 - Sikkens C8.16.48163122108 - Sikkens C9.21.311198169 - Sikkens C8.25.18936458 - Sikkens C8.03.78222209204 - Sikkens C4.05.65195177171 - - - Sikkens C8.05.65191173168 - Sikkens C8.05.55164147142 - Sikkens C8.10.50158133124 - Sikkens C8.10.40133108101 - Sikkens C8.10.30998074 - Sikkens C8.10.20846963 - Sikkens D2.03.86249234223 - - - Sikkens D2.05.80244219208 - Sikkens D2.10.70222184168 - Sikkens D2.10.60192162147 - Sikkens D2.10.50155131121 - Sikkens D2.10.40129108101 - Sikkens D2.10.301038780 - Sikkens D2.10.25876962 - - - Sikkens D2.10.80247211196 - Sikkens D2.15.75243194170 - Sikkens D2.15.65210166146 - Sikkens D2.15.55180142126 - Sikkens D3.18.4314711396 - Sikkens D2.20.301147863 - Sikkens D2.15.25906758 - - - Sikkens D2.15.15826460 - Sikkens D2.15.80246205184 - Sikkens D2.20.70234178151 - Sikkens D2.20.60201147121 - Sikkens D2.20.50173125103 - Sikkens D2.20.4014310082 - Sikkens D2.25.301217857 - - - Sikkens D2.30.301257858 - Sikkens D2.35.251247152 - Sikkens D2.30.70241160123 - Sikkens D2.30.60221144106 - Sikkens D2.35.5521212990 - Sikkens D2.30.5018111890 - Sikkens D2.35.4517210574 - - - Sikkens D2.30.401599768 - Sikkens D2.40.401639163 - Sikkens D2.40.301357855 - Sikkens D4.23.71237168131 - Sikkens D6.35.70244159107 - Sikkens D2.40.6023613690 - Sikkens D2.50.5523912269 - - - Sikkens D2.40.5019711276 - Sikkens D2.50.5020510261 - Sikkens D2.50.301437143 - Sikkens D4.30.201016246 - Sikkens D9.25.74251187142 - Sikkens D7.41.6022914690 - Sikkens D6.47.6124214079 - - - Sikkens D6.58.5824412248 - Sikkens D5.63.5323510836 - Sikkens D3.71.482299037 - Sikkens D3.65.432078535 - Sikkens D1.58.401947941 - Sikkens D9.40.6825016599 - Sikkens D6.50.6023813565 - - - Sikkens D8.65.5023010930 - Sikkens D6.60.5021010337 - Sikkens D6.50.5020210955 - Sikkens D6.60.401768835 - Sikkens D6.50.401679046 - Sikkens D6.50.301427543 - Sikkens D8.37.65239160102 - - - Sikkens D6.40.6022814084 - Sikkens D6.35.5520313592 - Sikkens D6.40.5019711466 - Sikkens D6.40.401589151 - Sikkens D6.40.301277648 - Sikkens D6.30.301258360 - Sikkens D6.30.20946348 - - - Sikkens D6.25.70247181141 - Sikkens D6.30.70245169124 - Sikkens D6.30.60219145103 - Sikkens D6.25.65223161125 - Sikkens D6.30.5017912388 - Sikkens D6.35.4517211275 - Sikkens D6.30.401489662 - - - Sikkens D6.25.301178261 - Sikkens D6.20.80251195162 - Sikkens D6.20.70228173142 - Sikkens D6.20.60202153125 - Sikkens D6.25.55192137108 - Sikkens D6.20.50173128102 - Sikkens D6.20.4013810079 - - - Sikkens D6.20.301087759 - Sikkens D6.20.20866451 - Sikkens D7.10.82251217198 - Sikkens D6.10.80245214196 - Sikkens D6.15.75239197175 - Sikkens D6.15.65210170147 - Sikkens D6.15.55175141121 - - - Sikkens D6.10.4012810997 - Sikkens D6.15.25916956 - Sikkens D6.10.20837163 - Sikkens D6.05.65242223212 - Sikkens D6.07.82242218203 - Sikkens D6.07.77229204189 - Sikkens D6.10.70213182164 - - - Sikkens D6.10.60185156139 - Sikkens D6.10.50155134121 - Sikkens D6.10.30998374 - Sikkens E0.05.25797064 - Sikkens DN.02.82233224218 - Sikkens D6.04.78229217207 - Sikkens D6.05.75223205195 - - - Sikkens D6.05.65196180171 - Sikkens D6.05.55165151144 - Sikkens D6.05.45138127121 - Sikkens D6.05.3511210398 - Sikkens D6.05.25867773 - Sikkens E0.03.88247236226 - Sikkens E0.03.84243231221 - - - Sikkens E0.05.75217201188 - Sikkens E0.10.70215187168 - Sikkens E0.10.60182158143 - Sikkens E0.10.50152131116 - Sikkens E0.05.45136125117 - Sikkens E0.10.30998778 - Sikkens E0.10.80243215196 - - - Sikkens E1.14.79247209179 - Sikkens E0.15.75239197167 - Sikkens E0.15.65205171146 - Sikkens E0.15.55175144121 - Sikkens E0.15.45145119100 - Sikkens E0.10.4012710995 - Sikkens E0.15.351179476 - - - Sikkens E0.20.80252202162 - Sikkens E0.20.70231183145 - Sikkens E0.25.65220168127 - Sikkens E0.20.60193152120 - Sikkens E0.20.50167129103 - Sikkens E0.25.4516111988 - Sikkens E0.20.4013510277 - - - Sikkens E0.20.301087654 - Sikkens E0.25.75250189141 - Sikkens E0.30.70245179127 - Sikkens E1.21.69225178139 - Sikkens E0.30.60207152109 - Sikkens E0.25.55187141105 - Sikkens E0.30.5017112182 - - - Sikkens E0.30.4014610165 - Sikkens E0.25.351289467 - Sikkens E0-35.65230163108 - Sikkens E0.40.6021514386 - Sikkens E0.35.5519813787 - Sikkens E0.45.5521112964 - Sikkens E0.50.5019811756 - - - Sikkens E0.40.5018812070 - Sikkens E0.40.4015910253 - Sikkens E0.45.301398747 - Sikkens E0.40.7024816396 - Sikkens E0.50.6525015065 - Sikkens E0.50.6023413862 - Sikkens E0.60.5020311540 - - - Sikkens E0.60.401679334 - Sikkens E0.50.401629546 - Sikkens E0.48.301297838 - Sikkens E0.30.20986848 - Sikkens E4.50.7025016674 - Sikkens E4.50.6022214458 - Sikkens E4.60.6023514132 - - - Sikkens E4.60.5019511730 - Sikkens E4.50.5018912248 - Sikkens E4.50.401579738 - Sikkens E0.40.301227842 - Sikkens E0.30.301208151 - Sikkens E5.27.77255201142 - Sikkens E4.30.75249189126 - - - Sikkens E4.40.7025117798 - Sikkens E4.40.6021414981 - Sikkens E4.40.5018612867 - Sikkens E4.40.4014510053 - Sikkens E4.40.301198042 - Sikkens E5.35.251007247 - Sikkens E6.19.80255213165 - - - Sikkens E4.30.70233176118 - Sikkens E4.30.60204156107 - Sikkens E4.30.5016612581 - Sikkens E1.22.4716713099 - Sikkens E4.30.4013910263 - Sikkens E4.30.301148762 - Sikkens E4.30.20866238 - - - Sikkens E4.20.80252210169 - Sikkens E4.25.75245188133 - Sikkens E4.25.65214166122 - Sikkens E1.26.54193146104 - Sikkens E4.25.55184142102 - Sikkens E4.25.351229466 - Sikkens E4.25.301148660 - - - Sikkens E4.15.25917560 - Sikkens E4.10.85251225195 - Sikkens E3.20.77247200159 - Sikkens E4.20.75243197153 - Sikkens E4.20.70221183146 - Sikkens E4.20.60196158127 - Sikkens E4.20.50167135106 - - - Sikkens E4.20.4013711082 - Sikkens E4.20.30997553 - Sikkens E5.08.84248227205 - Sikkens E4.07.82240220199 - Sikkens E4.10.80245220193 - Sikkens E4.15.75233199166 - Sikkens E4.15.65205171141 - - - Sikkens E4.15.55175147122 - Sikkens E4.15.4513911694 - Sikkens E4.15.351179577 - Sikkens E4.03.80222214207 - Sikkens E4.05.75216204191 - Sikkens E4.07.77223206189 - Sikkens E4.10.70209189171 - - - Sikkens E4.10.60179160142 - Sikkens E4.10.50153134117 - Sikkens E4.10.4012010796 - Sikkens E4.10.30928069 - Sikkens E4.05.85246231218 - Sikkens E6.07.84246228210 - Sikkens E1.09.78234210190 - - - Sikkens E3.09.72214194175 - Sikkens E0.11.67204179159 - Sikkens E0.09.53161142127 - Sikkens E8.10.4012311096 - Sikkens E8.10.301019078 - Sikkens EN.02.88246238230 - Sikkens EN.02.87246238230 - - - Sikkens EN.02.85238232225 - Sikkens E5.05.77214201185 - Sikkens E4.05.65186174163 - Sikkens E4.05.55158148140 - Sikkens E4.05.3510810094 - Sikkens E4.05.20736864 - Sikkens EN.02.90249243236 - - - Sikkens E4.04.83238226213 - Sikkens E8.05.80231219204 - Sikkens E8.07.77225210192 - Sikkens E8.05.70204194183 - Sikkens E8.03.60172165159 - Sikkens E4.05.45131122114 - Sikkens E4.05.25837772 - - - Sikkens E8.10.85251229205 - Sikkens E8.10.80235218196 - Sikkens E8.10.75221201176 - Sikkens E8.10.70211190167 - Sikkens E8.10.60176160142 - Sikkens E8.10.50149134118 - Sikkens E8.15.351109576 - - - Sikkens E8.10.25948577 - Sikkens E8.15.85251225192 - Sikkens E8.15.80243214180 - Sikkens E6.18.76241204163 - Sikkens E8.15.75223196164 - Sikkens E8.15.65201176146 - Sikkens E8.15.55171149123 - - - Sikkens E8.15.4513811998 - Sikkens E8.20.4013411388 - Sikkens E8.20.80247211165 - Sikkens E8.20.75240204155 - Sikkens E8.20.70217184140 - Sikkens E8.25.65209170120 - Sikkens E8.20.60186155119 - - - Sikkens E8.20.50160134104 - Sikkens E8.25.4514812087 - Sikkens E8.20.301048665 - Sikkens E8.25.80255210147 - Sikkens E8.25.75239195140 - Sikkens E8.30.70231183118 - Sikkens E8.35.65217168101 - - - Sikkens E8.30.60196156106 - Sikkens E8.25.55180146105 - Sikkens E5.21.49166135101 - Sikkens E8.30.4013510665 - Sikkens E8.35.75252190115 - Sikkens E8.40.7024017694 - Sikkens E8.40.6021215478 - - - Sikkens E8.35.5519214788 - Sikkens E8.35.4515711765 - Sikkens E8.40.4014310452 - Sikkens E8.25.351189464 - Sikkens E8.30.301128756 - Sikkens E9.44.7325218888 - Sikkens E8.50.7024417663 - - - Sikkens E8.55.7025117050 - Sikkens E8.60.6022814723 - Sikkens E8.50.6021415458 - Sikkens E8.50.5018613149 - Sikkens E8.40.5017813065 - Sikkens E8.55.4517911843 - Sikkens E9.29.79255207135 - - - Sikkens E7.36.76255195113 - Sikkens F0.40.7023918393 - Sikkens E8.49.7325518471 - Sikkens E6.58.6524016139 - Sikkens E7.72.582211390 - Sikkens E6.63.5320413024 - Sikkens E6.63.4718211523 - - - Sikkens F3.40.77254204100 - Sikkens F2.45.7525519271 - Sikkens F2.55.7525218739 - Sikkens F2.50.7023817955 - Sikkens F2.70.652401670 - Sikkens F2.60.7023817640 - Sikkens F2.60.6021115116 - - - Sikkens F2.50.5016912744 - Sikkens F3.33.80255214125 - Sikkens F2.35.80254205113 - Sikkens F2.40.7023318488 - Sikkens F2.50.6522216853 - Sikkens F2.50.6020715750 - Sikkens F2.50.5519114544 - - - Sikkens F2.50.4014610631 - Sikkens F2.40.301158434 - Sikkens F5.20.80242218164 - Sikkens F2.30.80249211140 - Sikkens F2.35.75243197115 - Sikkens F2.35.6521116999 - Sikkens F2.40.6019915780 - - - Sikkens F2.40.5016913361 - Sikkens F2.40.4013810647 - Sikkens F2.30.301058651 - Sikkens F2.25.75232199141 - Sikkens F2.30.70221185118 - Sikkens F2.25.65205175121 - Sikkens F2.30.60194159100 - - - Sikkens F2.25.55180150103 - Sikkens F2.30.5016513787 - Sikkens F2.25.4514311879 - Sikkens F2.30.4013611168 - Sikkens F2.15.85251223185 - Sikkens F0.20.80244211167 - Sikkens F0.24.80254214154 - - - Sikkens F0.25.75240205149 - Sikkens F0.30.70229186122 - Sikkens F0.25.70214178128 - Sikkens F1.22.52161137100 - Sikkens F2.20.4012810979 - Sikkens F0.10.85247228203 - Sikkens F0.17.80242214170 - - - Sikkens F2.15.80239215175 - Sikkens F2.20.80242212163 - Sikkens F2.20.70217189145 - Sikkens F2.20.60187162123 - Sikkens F2.20.50154133100 - Sikkens F2.20.30998257 - Sikkens F2.08.86249233210 - - - Sikkens F2.10.80234216190 - Sikkens F0.15.75222199166 - Sikkens F2.15.75229204166 - Sikkens F2.15.65193173141 - Sikkens F2.15.55164146118 - Sikkens F2.15.4513712196 - Sikkens F2.15.351119878 - - - Sikkens F2.07.88250238219 - Sikkens F2.06.84239228211 - Sikkens F2.06.74214206190 - Sikkens F2.10.70208192170 - Sikkens F2.10.60176163143 - Sikkens F2.10.50150138121 - Sikkens F2.10.4011911095 - - - Sikkens F2.10.30948470 - Sikkens F2.05.87248239222 - Sikkens F2.05.85243232216 - Sikkens F2.03.84235229221 - Sikkens F2.05.75211203191 - Sikkens F2.05.65184176165 - Sikkens F2.05.55160154145 - - - Sikkens F2.05.45129124116 - Sikkens F2.05.351059890 - Sikkens FN.02.88241235224 - Sikkens FN.02.82227221212 - Sikkens FN.02.77211207199 - Sikkens FN.02.67179176168 - Sikkens FN.02.57159155148 - - - Sikkens FN.02.47132129124 - Sikkens FN.02.3710710499 - Sikkens FN.02.27848176 - Sikkens F6.03.87243236225 - Sikkens F6.03.72203198189 - Sikkens F6.05.70200191178 - Sikkens F6.05.60168161149 - - - Sikkens F6.05.50144139128 - Sikkens F6.05.40117111103 - Sikkens F6.05.30948981 - Sikkens F6.03.20686562 - Sikkens FN.02.85233230222 - Sikkens F6.05.80227222204 - Sikkens F6.10.80229215190 - - - Sikkens F6.10.70200186162 - Sikkens F6.10.60174162139 - Sikkens F6.10.50151141123 - Sikkens F6.10.4012011196 - Sikkens F6.10.30938776 - Sikkens F2.03.88245239227 - Sikkens F6.07.77218206187 - - - Sikkens F6.15.75218200164 - Sikkens F6.15.65190175144 - Sikkens F6.15.55166152124 - Sikkens F8.16.48143132105 - Sikkens F6.15.45139127102 - Sikkens F6.15.3511310282 - Sikkens F6.07.82237225203 - - - Sikkens F6.15.80235216175 - Sikkens F6.20.80238213164 - Sikkens F6.20.70210190145 - Sikkens F6.20.60177158116 - Sikkens F6.20.50151134100 - Sikkens F6.20.4012611179 - Sikkens F6.20.30938257 - - - Sikkens F6.05.85242236221 - Sikkens F6.15.85248228187 - Sikkens F6.25.80244216148 - Sikkens F6.25.75231206149 - Sikkens F6.25.70212189129 - Sikkens F6.25.65201176119 - Sikkens F6.30.5015413280 - - - Sikkens F6.30.301048956 - Sikkens F6.09.86250236209 - Sikkens F6.20.83249227172 - Sikkens F6.26.81252219148 - Sikkens F6.35.75232197112 - Sikkens F6.30.70222188106 - Sikkens F6.35.65203174103 - - - Sikkens F6.30.60186162104 - Sikkens F6.30.4013311677 - Sikkens F6.25.85252223158 - Sikkens F6.30.80251214136 - Sikkens F6.40.80251207103 - Sikkens F6.40.7022318789 - Sikkens F6.40.6019416175 - - - Sikkens F6.40.5015612956 - Sikkens F6.40.4013411156 - Sikkens F6.40.301028742 - Sikkens F8.15.80233216173 - Sikkens F8.20.80235215166 - Sikkens F8.20.75224208159 - Sikkens F8.30.70214190120 - - - Sikkens F8.40.70219191100 - Sikkens F8.40.6019116477 - Sikkens F8.45.5518315558 - Sikkens F8.41.4614912862 - Sikkens F8.25.81239216151 - Sikkens F8.30.80244214134 - Sikkens F6.45.7524119574 - - - Sikkens F6.55.7524719854 - Sikkens F6.50.7022618752 - Sikkens F6.50.6019016053 - Sikkens F6.50.5016513141 - Sikkens F6.50.4013711040 - Sikkens G0.31.84247222130 - Sikkens F8.40.80253218105 - - - Sikkens F8.50.8025321068 - Sikkens F8.60.8025521236 - Sikkens F6.60.7022718318 - Sikkens F6.70.702351840 - Sikkens F6.60.6019815833 - Sikkens F6.55.5417914435 - Sikkens G0.35.85246226131 - - - Sikkens G0.40.80241216109 - Sikkens G0.45.8025122097 - Sikkens G0.50.8024721972 - Sikkens G0.55.8024821952 - Sikkens G0.60.702231902 - Sikkens G0.55.7021918937 - Sikkens G0.60.6018715927 - - - Sikkens G3.34.84249231128 - Sikkens G2.51.8124622474 - Sikkens G1.57.7924121543 - Sikkens G0.60.7523220613 - Sikkens G0.70.762422120 - Sikkens G0.50.7021718761 - Sikkens G0.50.6018716147 - - - Sikkens G0.50.5016014042 - Sikkens G5.27.85246237154 - Sikkens G4.34.84245234132 - Sikkens G3.46.8324622885 - Sikkens G0.35.75225204110 - Sikkens G0.40.7020518492 - Sikkens G0.40.6018015975 - - - Sikkens G0.40.5015013661 - Sikkens G0.40.4012511148 - Sikkens G0.25.85250229158 - Sikkens G0.30.80239216134 - Sikkens G0.25.75225206144 - Sikkens G0.30.70212190117 - Sikkens G0.25.65198179120 - - - Sikkens G0.30.60180162100 - Sikkens G0.30.5015113783 - Sikkens G0.30.30988952 - Sikkens G0.15.85249235188 - Sikkens G0.20.80237219163 - Sikkens G0.20.70207191140 - Sikkens G0.20.60180165122 - - - Sikkens G0.20.5014613598 - Sikkens G0.20.4012411482 - Sikkens G0.20.30958762 - Sikkens G0.20.20736750 - Sikkens GN.02.90246242230 - Sikkens G0.08.84235224196 - Sikkens G0.10.80227214186 - - - Sikkens G0.10.75214203174 - Sikkens G0.15.75217201161 - Sikkens G0.15.65186174140 - Sikkens G0.10.50144137117 - Sikkens G0.10.30928773 - Sikkens G0.05.88245239219 - Sikkens G0.08.88248238211 - - - Sikkens G0.09.85242233204 - Sikkens G1.13.79227216179 - Sikkens G0.10.70202193166 - Sikkens G0.10.65187179157 - Sikkens G0.10.60172163142 - Sikkens G0.10.4011510994 - Sikkens GN.01.88246245234 - - - Sikkens G4.04.88245240223 - Sikkens G0.05.85235229210 - Sikkens G0.05.75212206190 - Sikkens G0.05.65182178165 - Sikkens G0.05.55157152141 - Sikkens G0.05.45128125116 - Sikkens G0.05.25747166 - - - Sikkens GN.00.88239239236 - Sikkens G4.03.88245242231 - Sikkens G0.03.86235232219 - Sikkens G0.04.84233228214 - Sikkens G0.05.80224217202 - Sikkens G0.05.70197191177 - Sikkens G4.05.55157154144 - - - Sikkens G0.05.3510310093 - Sikkens G4.04.84237233217 - Sikkens G4.08.83234229204 - Sikkens G4.05.81224220205 - Sikkens G4.07.80225217197 - Sikkens G4.05.77214209194 - Sikkens G4.10.70198190162 - - - Sikkens G4.10.60171166145 - Sikkens G8.10.4011411396 - Sikkens G4.08.88247243218 - Sikkens G4.13.85244235196 - Sikkens G4.15.85241232185 - Sikkens G4.10.80227223196 - Sikkens G4.15.75211204163 - - - Sikkens G4.20.70205198147 - Sikkens G4.15.65186179146 - Sikkens G4.20.5014113496 - Sikkens G8.06.90244241221 - Sikkens G8.06.85233230209 - Sikkens G8.10.85239234204 - Sikkens G8.10.80227224188 - - - Sikkens G8.10.70194192167 - Sikkens G8.10.60166165140 - Sikkens G8.10.50141138119 - Sikkens G8.10.30858369 - Sikkens G9.13.85237236192 - Sikkens G8.20.85236231173 - Sikkens G8.20.80222219162 - - - Sikkens G8.20.70196196146 - Sikkens G8.20.60170169124 - Sikkens G8.20.5013913799 - Sikkens G8.20.4011611683 - Sikkens G8.20.30908861 - Sikkens G7.18.87246242182 - Sikkens G8.20.90242241177 - - - Sikkens G8.30.85238240144 - Sikkens G8.30.80225227136 - Sikkens G8.30.70199195117 - Sikkens G8.30.60171168102 - Sikkens G8.30.5014414485 - Sikkens G8.30.30919153 - Sikkens G5.24.85246237159 - - - Sikkens G5.36.83242232119 - Sikkens G8.40.80225229111 - Sikkens G8.40.7019819784 - Sikkens G8.40.6016516673 - Sikkens G8.40.5014013962 - Sikkens G8.30.4011511564 - Sikkens G8.30.20696838 - - - Sikkens G3.26.85248236164 - Sikkens G4.30.80234224145 - Sikkens G4.30.70203190112 - Sikkens G4.40.7020719390 - Sikkens G4.40.6017516371 - Sikkens G4.30.6017616799 - Sikkens G4.30.5014914083 - - - Sikkens G4.25.3510910567 - Sikkens G4.40.80232218103 - Sikkens G4.50.8023021670 - Sikkens G4.50.7019919158 - Sikkens G4.60.7020019110 - Sikkens G4.60.601771640 - Sikkens G4.50.6017316145 - - - Sikkens G4.40.5014913959 - Sikkens G4.50.5015014040 - Sikkens G8.45.8021722173 - Sikkens G8.50.7018819250 - Sikkens G7.57.6618519042 - Sikkens G8.60.6017017021 - Sikkens G8.50.6016416852 - - - Sikkens G8.50.5014214036 - Sikkens G8.40.4011711953 - Sikkens G8.40.30919344 - Sikkens H1.39.83222236115 - Sikkens G9.56.7620421950 - Sikkens G8.60.7018919726 - Sikkens H2.55.6516418846 - - - Sikkens H2.60.6015117516 - Sikkens H2.50.6015617149 - Sikkens H2.50.5013214240 - Sikkens H2.45.4512013352 - Sikkens G9.27.84232236151 - Sikkens G9.35.84230236126 - Sikkens H2.40.80214224102 - - - Sikkens H2.45.7519721488 - Sikkens H2.50.7018020061 - Sikkens H2.40.7018720094 - Sikkens H2.40.6016216977 - Sikkens H2.40.5013814769 - Sikkens H2.15.85234240187 - Sikkens H2.30.80211223134 - - - Sikkens H3.24,78210220151 - Sikkens H2.20.70191196142 - Sikkens H2.30.70187195118 - Sikkens H2.30.60159170101 - Sikkens H2.30.5013314084 - Sikkens H2.35.30889248 - Sikkens H4.06.85234237216 - - - Sikkens H2.07.87239239213 - Sikkens H6.06.80220222198 - Sikkens H3.12.78211216180 - Sikkens H4.16.70187195153 - Sikkens H3.18.60162168127 - Sikkens H2.20.5013714099 - Sikkens H2.25.30868959 - - - Sikkens H2.08.90245244216 - Sikkens H2.04.88241241223 - Sikkens H2.08.80224222200 - Sikkens H2.10.80221221193 - Sikkens H2.10.70190192165 - Sikkens H2.10.60166169144 - Sikkens H3.10.43120122104 - - - Sikkens H2.12.25787965 - Sikkens H2.03.90246245229 - Sikkens HN.02.85233233222 - Sikkens HN.02.88240239231 - Sikkens H2.03.82224225214 - Sikkens H3.04.72197198185 - Sikkens H2.05.65178178166 - - - Sikkens H2.05.55150149136 - Sikkens H2.05.45131132123 - Sikkens J0.04.88239243226 - Sikkens JN.02.82222226217 - Sikkens J0.05.75201207188 - Sikkens J0.05.65176180165 - Sikkens J0.05.55149152139 - - - Sikkens J0.05.45124126116 - Sikkens J0.05.3510010294 - Sikkens J0.05.25767770 - Sikkens J0.05.85227230211 - Sikkens J0.07.82221228204 - Sikkens J0.07.77207214190 - Sikkens J0.10.70185193167 - - - Sikkens J0.10.60162168147 - Sikkens J0.10.50134140120 - Sikkens J0.10.4011011499 - Sikkens J0.10.30868975 - Sikkens J0.15.85221238189 - Sikkens J0.10.80209220189 - Sikkens J0.15.75197210166 - - - Sikkens J0.15.65170183145 - Sikkens J0.20.60154170123 - Sikkens J0.20.50123139100 - Sikkens J0.20.4010511683 - Sikkens J0.15.25778365 - Sikkens J0.20.80203225165 - Sikkens J0.20.70178196146 - - - Sikkens J0.30.70171197120 - Sikkens J0.30.60147170105 - Sikkens J0.30.5012714690 - Sikkens J0.30.409711769 - Sikkens J0.20.30818961 - Sikkens J0.20.20657254 - Sikkens H9.21.83214235173 - - - Sikkens J0.30.80198224139 - Sikkens J0.40.7015520198 - Sikkens J0.40.6013817482 - Sikkens J0.40.5011014261 - Sikkens J0.40.409612059 - Sikkens J0.30.30879962 - Sikkens J0.30.20627240 - - - Sikkens H7.22.83214235164 - Sikkens H7.38.79195229113 - Sikkens J0.48.7015520476 - Sikkens J0.50.6012717358 - Sikkens J0.50.5010814749 - Sikkens J0.60.5010614932 - Sikkens J0.50.409212041 - - - Sikkens J0.40.30749437 - Sikkens J1.21.83208235174 - Sikkens J6.26.76163213141 - Sikkens J8.41.70135211114 - Sikkens J6.39.63132189102 - Sikkens J6.45.5310416074 - Sikkens J6.51.437913457 - - - Sikkens J7.41.357411156 - Sikkens J4.44.25558340 - Sikkens J9.19.80191230177 - Sikkens K0.28.76164221149 - Sikkens J8.48.6411419686 - Sikkens K2.50.608418182 - Sikkens K2.50.506815368 - - - Sikkens K2.50.406113058 - Sikkens K2.60.404313547 - Sikkens K6.50.304510455 - Sikkens J6.11.83206229193 - Sikkens K0.26.76168221154 - Sikkens K2.40.70128208116 - Sikkens K2.40.60110178100 - - - Sikkens K2.40.508815384 - Sikkens K2.40.407312467 - Sikkens K2.40.30579852 - Sikkens K2.45.20477946 - Sikkens K0.19.78189227178 - Sikkens K5.21.77168221172 - Sikkens K6.34.67123200131 - - - Sikkens K5.44.6492197107 - Sikkens K7.48.566117796 - Sikkens K7.58.44814269 - Sikkens K7.60.30010545 - Sikkens K4.54.23418744 - Sikkens K2.10.80203224197 - Sikkens K2.21.72162208156 - - - Sikkens K2.30.70144205135 - Sikkens K2.30.60120174114 - Sikkens K2.30.5010515098 - Sikkens K2.30.407911975 - Sikkens K2.30.30669863 - Sikkens K2.29.20527351 - Sikkens K0.09.85217239210 - - - Sikkens K2.20.80184228175 - Sikkens K2.20.70163201155 - Sikkens K2.20.60137171128 - Sikkens K2.20.50119147113 - Sikkens K2.20.4510012698 - Sikkens K2.20.409211687 - Sikkens K2.20.30729268 - - - Sikkens K2.09.79199218192 - Sikkens K2.10.70177196169 - Sikkens K2.15.65161186154 - Sikkens K2.10.60154169150 - Sikkens K2.10.50127141121 - Sikkens K2.10.4010011295 - Sikkens K2.10.30809076 - - - Sikkens K2.10.20626758 - Sikkens KN.02.87236242230 - Sikkens K2.05.85228237223 - Sikkens K2.05.75201210196 - Sikkens K2.05.65167177164 - Sikkens K2.05.50130136126 - Sikkens K2.05.43114120111 - - - Sikkens K3.06.34939891 - Sikkens K2.05.25697466 - Sikkens LN.02.88232240234 - Sikkens LN.02.86230239232 - Sikkens LN.02.77204211202 - Sikkens LN.02.67180185178 - Sikkens LN.02.57151155150 - - - Sikkens LN.02.47124127122 - Sikkens LN.02.379910499 - Sikkens LN.02.27788177 - Sikkens L0.03.88216227217 - Sikkens L0.04.78203216204 - Sikkens L0.05.75194207194 - Sikkens L0.05.65169179170 - - - Sikkens L0.05.55143153143 - Sikkens L0.05.45117125117 - Sikkens L0.05.359410194 - Sikkens L0.05.25737973 - Sikkens L0.05.85219236220 - Sikkens L0.07.82208230210 - Sikkens L0.07.77196219198 - - - Sikkens L0.10.70178201180 - Sikkens L0.10.60144164147 - Sikkens L0.10.50126144129 - Sikkens L0.10.4098115101 - Sikkens L0.10.30799281 - Sikkens L3.10.82200233210 - Sikkens L0.15.75171212175 - - - Sikkens L0.15.65147184155 - Sikkens L0.15.55127157130 - Sikkens K8.23.4598135101 - Sikkens L0.20.30689473 - Sikkens L0.20.20487053 - Sikkens L0.15.20567058 - Sikkens L0.10.80192223197 - - - Sikkens L0.20.70150198159 - Sikkens L0.20.60129171135 - Sikkens L0.20.50108148115 - Sikkens L0.20.409111996 - Sikkens L0.30.30499260 - Sikkens L0.35.20417251 - Sikkens L0.20.15446250 - - - Sikkens L3.17.75153211176 - Sikkens L0.30.60101173123 - Sikkens L0.30.5086149103 - Sikkens L0.30.406412180 - Sikkens L0.40.355511573 - Sikkens L0.40.30409761 - Sikkens L0.30.20417148 - - - Sikkens L0.15.15455545 - Sikkens L0.30.70128209149 - Sikkens L0.40.6084185117 - Sikkens L0.50.503815381 - Sikkens L0.40.506614894 - Sikkens L0.40.404812073 - Sikkens L0.50.403113070 - - - Sikkens L0.70.40013157 - Sikkens L0.50.302811061 - Sikkens L9.13.79178226199 - Sikkens L5.21.73146211174 - Sikkens L4.32.66102200144 - Sikkens L8.40.6054186130 - Sikkens L5.47.526162105 - - - Sikkens L5.60.43014179 - Sikkens L5.63.33011465 - Sikkens L6.59.2308651 - Sikkens L8.20.70141204172 - Sikkens L8.30.70104206159 - Sikkens L8.30.6098182143 - Sikkens L8.50.500160103 - - - Sikkens L8.50.40013185 - Sikkens L8.40.352411783 - Sikkens L8.40.302310171 - Sikkens L8.50.30010370 - Sikkens L8.05.85219237223 - Sikkens L8.05.75191206196 - Sikkens L8.10.60146171157 - - - Sikkens L8.19.60116174145 - Sikkens L8.20.5099146122 - Sikkens L8.30.5073151113 - Sikkens L8.30.405812192 - Sikkens L8.30.30519675 - Sikkens M5.09.83205236219 - Sikkens L8.10.80188223202 - - - Sikkens L9.17.79172230201 - Sikkens M2.20.75138213186 - Sikkens M2.40.6046186140 - Sikkens M2.30.6090181146 - Sikkens M2.40.5018157118 - Sikkens M2.30.5072151121 - Sikkens M8.06.85214239228 - - - Sikkens N0.12.78176221207 - Sikkens N1.21.65119192177 - Sikkens N1.37.5528168147 - Sikkens N0.40.500150128 - Sikkens N0.50.500156130 - Sikkens N0.50.400129107 - Sikkens N0.40.30010187 - - - Sikkens M8.11.81192232217 - Sikkens M8.19.72139211195 - Sikkens N0.28.7099208183 - Sikkens N0.30.6085179160 - Sikkens N0.30.5060152133 - Sikkens N0.40.400128108 - Sikkens N0.30.4047119104 - - - Sikkens N0.40.2067866 - Sikkens N0.10.80188223214 - Sikkens N0.20.70137200184 - Sikkens N0.20.60120178162 - Sikkens N0.20.5095150136 - Sikkens N0.20.4073120108 - Sikkens N0.20.30589686 - - - Sikkens N0.20.20417063 - Sikkens N0.15.10415148 - Sikkens N0.05.85222236232 - Sikkens N8.09.81196231227 - Sikkens N0.10.70165199188 - Sikkens N0.10.60145174165 - Sikkens N0.10.50116141133 - - - Sikkens N0.10.4092113107 - Sikkens N0.10.30739085 - Sikkens N0.10.20596966 - Sikkens NN.01.84227229227 - Sikkens N7.04.73189204201 - Sikkens N6.08.70171196191 - Sikkens N1.06.88172189184 - - - Sikkens N0.05.65172184180 - Sikkens N1.05.55138152148 - Sikkens N0.05.44114124121 - Sikkens M8.06.349010197 - Sikkens PN.02.82218228226 - Sikkens PN.02.77203212210 - Sikkens P0.05.75191208204 - - - Sikkens P0.05.65165180178 - Sikkens P0.03.60160167165 - Sikkens P0.03.55141148148 - Sikkens P0.03.359110098 - Sikkens P0.05.25727877 - Sikkens P0.05.83211230226 - Sikkens P0.07.85212234230 - - - Sikkens P0.07.77191217214 - Sikkens P0.06.74185208205 - Sikkens P5.08.58144164164 - Sikkens P0.05.55138153150 - Sikkens P0.05.45119130128 - Sikkens P0.10.30719189 - Sikkens P1.03.79211222220 - - - Sikkens P0.04.78200216214 - Sikkens P0.10.70163197193 - Sikkens P1.11.63147178177 - Sikkens P0.10.60143170167 - Sikkens P0.10.50118144142 - Sikkens P0.10.4097117117 - Sikkens P0.10.20536765 - - - Sikkens P4.10.75178208207 - Sikkens P1.09.69166194193 - Sikkens P0.15.65138185181 - Sikkens P0.15.55116156153 - Sikkens P0.15.4598129126 - Sikkens P0.15.3576106104 - Sikkens P0.20.30579491 - - - Sikkens P0.20.20426968 - Sikkens P0.10.80189227223 - Sikkens P0.20.70129203197 - Sikkens P0.20.60110175170 - Sikkens P0.20.5094148145 - Sikkens P0.30.4051127123 - Sikkens P0.20.4078122120 - - - Sikkens P0.30.30379995 - Sikkens P0.30.25308583 - Sikkens P0.15.75161215209 - Sikkens P0.30.6080181178 - Sikkens P0.30.5059150145 - Sikkens P0.40.500155151 - Sikkens P0.50.400130127 - - - Sikkens P0.40.400126122 - Sikkens P0.50.30010299 - Sikkens P0.40.300104102 - Sikkens P2.11.80187227225 - Sikkens P2.20.73136210210 - Sikkens P2.32.6374190190 - Sikkens P3.35.5336160165 - - - Sikkens P3.40.440136138 - Sikkens P2.46.330107109 - Sikkens P4.48.2709396 - Sikkens Q0.40.2007581 - Sikkens Q0.30.6087174183 - Sikkens Q0.30.5060148156 - Sikkens P9.38.4917149162 - - - Sikkens Q0.40.400124135 - Sikkens Q0.50.400128141 - Sikkens Q0.50.300101111 - Sikkens Q0.40.30099105 - Sikkens Q0.50.2007582 - Sikkens P9.14.77170219224 - Sikkens Q0.20.73143210218 - - - Sikkens Q0.29.6598191203 - Sikkens Q0.40.5031151162 - Sikkens Q0.30.4039118126 - Sikkens Q0.30.304196103 - Sikkens Q0.30.20277175 - Sikkens Q1.19.10355153 - Sikkens Q0.09.81196225229 - - - Sikkens Q0.10.80190223225 - Sikkens Q0.20.70140199208 - Sikkens Q0.20.60122174180 - Sikkens Q0.20.5093144150 - Sikkens Q0.20.4078117121 - Sikkens Q0.20.30528892 - Sikkens Q0.20.20497477 - - - Sikkens Q0.05.84217234235 - Sikkens Q6.09.81201228234 - Sikkens Q6.10.80196227233 - Sikkens Q5.10.76187216222 - Sikkens Q0.10.70167197200 - Sikkens Q0.10.60145170173 - Sikkens Q0.10.50119141144 - - - Sikkens Q0.10.4097117120 - Sikkens QN.02.88238244243 - Sikkens QN.02.78215218217 - Sikkens P9.05.75192209210 - Sikkens Q0.05.65166182183 - Sikkens Q0.05.55147156156 - Sikkens Q5.05.44113123125 - - - Sikkens Q0.10.30759092 - Sikkens Q0.10.20526262 - Sikkens R0.05.85219232235 - Sikkens Q5.06.80203220224 - Sikkens R0.05.75196211214 - Sikkens Q9.12.74176209220 - Sikkens R0.10.60148169176 - - - Sikkens Q9.10.50120139145 - Sikkens Q9.10.4098114120 - Sikkens R3.10.30799297 - Sikkens R0.10.80195220227 - Sikkens Q7.15.76171216227 - Sikkens R0.20.70146199217 - Sikkens R0.20.60123170185 - - - Sikkens R0.20.50106145158 - Sikkens R0.30.4063121137 - Sikkens R0.30.304895108 - Sikkens R0.20.15375762 - Sikkens R0.13.77182217228 - Sikkens R1.17.73161206220 - Sikkens R1.23.72146205225 - - - Sikkens R0.30.60107175196 - Sikkens R0.30.5076148165 - Sikkens R0.40.4020122146 - Sikkens R0.40.30095111 - Sikkens R0.28.19367080 - Sikkens R2.20.73156208227 - Sikkens R2.22.66136189209 - - - Sikkens R2.29.68124197223 - Sikkens R2.33.5581162189 - Sikkens R0.40.5038152179 - Sikkens R0.50.400126154 - Sikkens R0.50.300101123 - Sikkens R1.35.23298095 - Sikkens R4.14.80175214225 - - - Sikkens R5.23.70130190214 - Sikkens R5.37.5673163197 - Sikkens R4.42.4743141175 - Sikkens R5.52.490146191 - Sikkens R6.56.360112147 - Sikkens R7.43.262287112 - Sikkens R9.35.22347797 - - - Sikkens R7.22.69145196221 - Sikkens R7.31.62112179213 - Sikkens R6.31.5288152179 - Sikkens R7.33.4777138166 - Sikkens R7.44.4133125160 - Sikkens R8.40.3945116148 - Sikkens R8.33.3653106130 - - - Sikkens S0.40.2017294 - Sikkens S0.30.60112172206 - Sikkens S0.40.6075176218 - Sikkens S0.50.500150199 - Sikkens S0.40.5060144185 - Sikkens S0.50.400123165 - Sikkens S0.40.4037118153 - - - Sikkens S0.50.30097133 - Sikkens S0.40.302493123 - Sikkens S4.26.64131183219 - Sikkens S1.38.5681163208 - Sikkens S4.43.4958144194 - Sikkens S4.43.4647136182 - Sikkens S3.56.420129186 - - - Sikkens S5.48.4119124173 - Sikkens S4.50.350104147 - Sikkens S5.44.301695131 - Sikkens S1.13.73177202218 - Sikkens S0.20.70158199222 - Sikkens S0.20.60131168189 - Sikkens S0.30.5086145174 - - - Sikkens S0.30.4070119144 - Sikkens S0.30.304892113 - Sikkens S1.37.253482108 - Sikkens S0.30.20347088 - Sikkens S0.10.80198224235 - Sikkens S0.15.80191219234 - Sikkens S0.15.65150181197 - - - Sikkens S0.15.55126154170 - Sikkens S0.20.50107141161 - Sikkens S0.20.4084116133 - Sikkens S0.20.306288101 - Sikkens S0.20.20517182 - Sikkens S0.05.85219232238 - Sikkens S0.11.78187215229 - - - Sikkens S0.10.70180199210 - Sikkens S0.10.60152168178 - Sikkens S0.10.50124141150 - Sikkens S0.10.40102114122 - Sikkens S0.10.30799096 - Sikkens S0.10.20566570 - Sikkens SN.02.85229235237 - - - Sikkens SN.02.82217223223 - Sikkens SN.02.77206210211 - Sikkens SN.02.67178185187 - Sikkens SN.02.57155158160 - Sikkens SN.02.47126128130 - Sikkens SN.02.37105107108 - Sikkens SN.02.27828484 - - - Sikkens S0.04.78205214217 - Sikkens S0.07.77198215222 - Sikkens S0.05.75193205210 - Sikkens S0.05.65167177182 - Sikkens S0.05.55146152154 - Sikkens S0.05.45120127131 - Sikkens S0.05.3596102105 - - - Sikkens S0.05.25727780 - Sikkens S6.09.77196211226 - Sikkens S2.10.71175196209 - Sikkens S3.10.73184205218 - Sikkens S8.10.65161177192 - Sikkens S8.12.55135152167 - Sikkens S8.15.4199118135 - - - Sikkens S8.10.3591101110 - Sikkens S8.14.17526373 - Sikkens S2.07.80206221231 - Sikkens S6.14.75183210231 - Sikkens S8.12.68170189207 - Sikkens S6.15.65157182205 - Sikkens S7.19.60137167194 - - - Sikkens S6.29.4382125157 - Sikkens S9.32.3562105137 - Sikkens S8.22.296788109 - Sikkens S7.17.72171202230 - Sikkens S6.21.66148186217 - Sikkens S7.27.61128174211 - Sikkens S8.34.5499155201 - - - Sikkens S9.35.56107161208 - Sikkens S9.39.4160120167 - Sikkens S8.48.28090135 - Sikkens T2.47.24082130 - Sikkens S8.16.73177204230 - Sikkens T0.20.70160191223 - Sikkens S9.25.65138181222 - - - Sikkens T0.30.60118168212 - Sikkens T0.40.5072143198 - Sikkens T0.50.400123184 - Sikkens T0.50.30095145 - Sikkens T0.45.203180114 - Sikkens T0.12.73181201222 - Sikkens S9.18.69160189218 - - - Sikkens T0.20.60134165194 - Sikkens T6.39.5296149213 - Sikkens T0.40.4052118166 - Sikkens T0.45.3531106153 - Sikkens T0.40.303791130 - Sikkens T0.40.202174111 - Sikkens T0.10.80208220235 - - - Sikkens T2.17.69167194223 - Sikkens T3.23.65149182223 - Sikkens T0.30.5095141180 - Sikkens T0.30.4078118153 - Sikkens T4.36.274686127 - Sikkens T0.30.305592121 - Sikkens T0.30.20386996 - - - Sikkens T3.06.78209217227 - Sikkens T0.13.64160177198 - Sikkens T4.18.59139164196 - Sikkens T3.20.50118142173 - Sikkens T0.23.48105136166 - Sikkens T4.27.3677105140 - Sikkens T4.40.303586128 - - - Sikkens T0.25.255579102 - Sikkens T3.06.80205215227 - Sikkens T0.10.70175191208 - Sikkens T0.10.60147162177 - Sikkens T3.14.55135152173 - Sikkens T0.20.50111138163 - Sikkens T0.20.4092116138 - - - Sikkens T0.20.306589110 - Sikkens T0.20.20526987 - Sikkens TN.02.82224226228 - Sikkens S7.04.85227235243 - Sikkens S7.05.75196204211 - Sikkens S8.05.65170177184 - Sikkens T0.10.50127138152 - - - Sikkens T0.10.40102113124 - Sikkens T0.10.307989100 - Sikkens T0.10.20596675 - Sikkens UN.02.82229228233 - Sikkens UN.02.85231233238 - Sikkens UN.02.77211211216 - Sikkens U0.05.75205209220 - - - Sikkens U0.05.65174177187 - Sikkens U0.05.55147151160 - Sikkens U0.05.45122125133 - Sikkens U0.05.359799106 - Sikkens U0.04.84229231236 - Sikkens T7.07.77203210227 - Sikkens T9.07.67176184197 - - - Sikkens U0.10.60159165183 - Sikkens U0.10.50128135154 - Sikkens U0.10.40107112128 - Sikkens U0.10.308389102 - Sikkens U0.10.20596373 - Sikkens T9.07.82219224240 - Sikkens T8.10.73190201222 - - - Sikkens U0.15.65160176208 - Sikkens U0.15.55134149176 - Sikkens U0.20.50123139174 - Sikkens U0.20.4096112142 - Sikkens U0.20.307085114 - Sikkens U0.20.20607092 - Sikkens U0.10.70181195217 - - - Sikkens U0.20.70170187223 - Sikkens U0.20.60144165206 - Sikkens U0.25.60135164209 - Sikkens U0.30.50111140194 - Sikkens U0.30.4083111159 - Sikkens U0.30.306891130 - Sikkens U0.30.20466498 - - - Sikkens U2.09.75189198220 - Sikkens U3.14.67171181211 - Sikkens U2.16.60151163195 - Sikkens U4.30.52121140201 - Sikkens U0.40.4069112173 - Sikkens U0.40.304986137 - Sikkens U0.40.203673116 - - - Sikkens U0.30.16395582 - Sikkens U3.07.78196204222 - Sikkens U3.20.64175191221 - Sikkens U3.19.64158174218 - Sikkens U4.25.55132149201 - Sikkens U4.35.4597121188 - Sikkens U5.44.356398173 - - - Sikkens U5.19.25445691 - Sikkens U2.21.09344360 - Sikkens U7.03.81210212222 - Sikkens U6.06.73198199216 - Sikkens U4.10.72193200224 - Sikkens U6.18.63164171215 - Sikkens U6.26.46113125175 - - - Sikkens U7.27.3692101149 - Sikkens U6.25.256776112 - Sikkens U4.15.10424558 - Sikkens VN.02.85233232236 - Sikkens U9.05.75203203217 - Sikkens V0.15.65177180212 - Sikkens V0.25.55137144199 - - - Sikkens V0.37.42103113185 - Sikkens V1.42.307284156 - Sikkens V0.40.307082148 - Sikkens V0.47.194462125 - Sikkens V0.07.80221220236 - Sikkens V0.10.70187187214 - Sikkens V0.20.60157159208 - - - Sikkens V0.20.50137141182 - Sikkens V0.25.45115120169 - Sikkens V0.30.40104111170 - Sikkens V0.30.307683132 - Sikkens U9.30.205864110 - Sikkens V7.03.81225222228 - Sikkens V2.08.71195194217 - - - Sikkens V5.14.60167161197 - Sikkens V4.25.55149142203 - Sikkens V5.25.45125117172 - Sikkens V0.25.359697141 - Sikkens V0.25.257175115 - Sikkens V3.35.14464797 - Sikkens V8.04.79216211224 - - - Sikkens V9.08.73205196220 - Sikkens V9.11.69189176208 - Sikkens V6.20.55155144192 - Sikkens V9.17.54147135181 - Sikkens V8.23.44129117166 - Sikkens V9.26.39115100156 - Sikkens V9.26.3310185136 - - - Sikkens W0.05.75210202219 - Sikkens W0.08.71201191214 - Sikkens W0.10.70196186214 - Sikkens W0.20.60172156201 - Sikkens W0.25.50147128186 - Sikkens W0.25.40122103160 - Sikkens W0.30.40120102165 - - - Sikkens W0.30.3010184141 - Sikkens W1.03.81227220231 - Sikkens W0.07.77213205225 - Sikkens W0.15.65183169209 - Sikkens W0.15.55155142180 - Sikkens W0.15.45131120153 - Sikkens W0.20.50147129179 - - - Sikkens W0.20.40119105150 - Sikkens W0.20.309381116 - Sikkens WN.02.82230225232 - Sikkens WN.02.77212207212 - Sikkens W0.05.70205197211 - Sikkens W0.10.60173161187 - Sikkens W0.10.50141136159 - - - Sikkens W0.10.40118109129 - Sikkens W0.10.309386106 - Sikkens W0.10.20575370 - Sikkens W0.03.84231223229 - Sikkens W0.04.78217212223 - Sikkens W0.05.65179175187 - Sikkens W0.05.55154148162 - - - Sikkens W0.05.45129123135 - Sikkens W0.05.3510499109 - Sikkens W0.05.25817788 - Sikkens W0.05.15585562 - Sikkens XN.01.83234228230 - Sikkens W6.05.72208196210 - Sikkens W8.05.63181170185 - - - Sikkens W6.07.53153142160 - Sikkens W7.07.44130119136 - Sikkens W7.07.3410696111 - Sikkens W7.08.24827387 - Sikkens X0.08.15645566 - Sikkens XN.01.84234227227 - Sikkens X0.04.80225213222 - - - Sikkens X0.10.70207186212 - Sikkens X0.10.60179157184 - Sikkens X0.10.50150130155 - Sikkens X0.10.40124106128 - Sikkens X0.10.309781100 - Sikkens X0.10.20695977 - Sikkens ZN.01.83237231230 - - - Sikkens X6.04.76220205217 - Sikkens X7.07.69204182199 - Sikkens X7.13.58184150179 - Sikkens X0.15.50158130167 - Sikkens X0.20.50158125172 - Sikkens X0.20.40131101141 - Sikkens X0.20.3010578112 - - - Sikkens X9.04.79230215224 - Sikkens X9.06.79236212229 - Sikkens X9.11.74230195221 - Sikkens X7.16.66211165205 - Sikkens X7.18.54179133173 - Sikkens X9.21.44157106148 - Sikkens X9.24.3312877118 - - - Sikkens Y0.30.18914889 - Sikkens YN.01.83236229230 - Sikkens YN.02.82234222225 - Sikkens Y1.08.73222194213 - Sikkens Y0.12.63200163191 - Sikkens Y0.20.50170122161 - Sikkens Y0.20.4013995133 - - - Sikkens Y0.20.3010870101 - Sikkens Y0.20.20915886 - Sikkens YN.02.77216206209 - Sikkens Y0.05.72211193200 - Sikkens Y0.10.70214181205 - Sikkens Y0.10.60188155179 - Sikkens Y0.10.50158130152 - - - Sikkens Y0.10.40129105124 - Sikkens Y0.10.30977893 - Sikkens Y0.10.20746073 - Sikkens ZN.01.85238230231 - Sikkens Z2.06.81239209217 - Sikkens Z1.10.70222180198 - Sikkens Z0.10.60188155169 - - - Sikkens Z0.10.50159127142 - Sikkens Z0.10.40135106118 - Sikkens Z0.10.301078293 - Sikkens Z0.05.20726468 - Sikkens Z8.05.81234207210 - Sikkens Z1.10.80252207226 - Sikkens Z1.14.71235178203 - - - Sikkens Z0.20.60211139174 - Sikkens Z0.20.50176121148 - Sikkens Z0.20.4014992122 - Sikkens Z0.20.301207197 - Sikkens Z0.19.20935878 - Sikkens Z9.10.77239191200 - Sikkens Z4.09.76236190205 - - - Sikkens Z6.23.60224128160 - Sikkens Z5.31.4819597140 - Sikkens Z3.37.4319181132 - Sikkens Z9.35.3817574110 - Sikkens Z2.32.3315269107 - Sikkens Z0.30.3013365103 - Sikkens Z8.11.75243194207 - - - Sikkens Z9.16.68235167186 - Sikkens Z8.18.63226151173 - Sikkens Z8.23.57215129157 - Sikkens Z9.32.4920999136 - Sikkens Z9.30.3515970100 - Sikkens Z8.23.271216584 - Sikkens Z3.23.241085477 - - diff --git a/src/ui/color_select/book.rs b/src/ui/color_select/book.rs index 15fe2df1a..6a9a74f2f 100644 --- a/src/ui/color_select/book.rs +++ b/src/ui/color_select/book.rs @@ -57,15 +57,10 @@ impl ColorBook { } } -/// The colour books that ship with the program; their licences are in the -/// files' header comments. +/// The colour book that ships with the program; its licence is in the +/// file's header comment. pub fn builtin() -> impl Iterator { - [ - include_str!("../../../assets/color_books/ral-classic.acb"), - include_str!("../../../assets/color_books/sikkens-4041.acb"), - ] - .into_iter() - .filter_map(|text| parse(text).ok()) + parse(include_str!("../../../assets/color_books/ral-classic.acb")).into_iter() } /// Read an `.acb` file's text. From 3aa62d5230f0db1b8bf862c7b38dbba0d799d9d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:17:18 +0000 Subject: [PATCH 27/32] Select Color: Phosphor eyedropper icon; load button and page arrows in active-tab colours - The pipette is Phosphor Icons' "eyedropper" (regular, MIT); its licence is in assets/icons/ui/LICENSE-phosphor.txt and the SVG header. - The load-book button and the page strip's up/down arrows use the active tab's colours (background.strong and its text colour), slightly darker on hover. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- assets/icons/ui/LICENSE-phosphor.txt | 21 +++++++++++++++++++++ assets/icons/ui/eyedropper.svg | 3 ++- src/ui/color_select/picker.rs | 24 +++++++++++++++++------- 3 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 assets/icons/ui/LICENSE-phosphor.txt 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 index fe34cd3f9..fd3e85c09 100644 --- a/assets/icons/ui/eyedropper.svg +++ b/assets/icons/ui/eyedropper.svg @@ -1 +1,2 @@ - + + diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 157c139c0..f2421bfb7 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -730,7 +730,7 @@ impl ColorPicker { let load_button = |content: Element<'static, Message>| { button(content) .on_press(picker_msg(ColorPickerMsg::LoadBook)) - .style(button::secondary) + .style(tab_button_style) }; let Some(book) = self.books.get(self.book) else { return container( @@ -761,7 +761,7 @@ impl ColorPicker { .width(Length::Fill), load_button( container( - secondary_icon(crate::ui::icons::themed_handle( + tab_icon(crate::ui::icons::themed_handle( crate::ui::icons::FOLDER_OPEN )) .width(16) @@ -854,14 +854,14 @@ impl ColorPicker { .width(9) .height(5) .style(|theme: &Theme, _status| svg::Style { - color: Some(theme.palette().secondary.base.text), + color: Some(theme.palette().background.strong.text), }), ) .on_press(picker_msg(ColorPickerMsg::BookPage(page))) .width(Length::Fill) .height(18) .padding([6, 8]) - .style(button::secondary) + .style(tab_button_style) }; let strip = column![ arrow("M0 4L3.5 0L7 4z", self.book_page.saturating_sub(1)), @@ -1139,10 +1139,20 @@ fn search_box<'a>( .into() } -/// An icon in the text colour of a secondary button, so it reads on it. -fn secondary_icon<'a>(handle: svg::Handle) -> svg::Svg<'a> { +/// 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().secondary.base.text), + color: Some(theme.palette().background.strong.text), }) } From 345f836e9150afc3de7909d8273e57ca230a0ae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:27:58 +0000 Subject: [PATCH 28/32] Select Color: review cleanups - Arrow keys only walk the colours on screen: the Document tab's search results, and the Color books matches while searching (the whole book, page after page, otherwise). One helper per tab now gives the visible colours to both the view and the arrow keys. - show_identity no longer returns an unused bool; ink() absorbs its one-use ink_is_dark helper. - document::layer_identity joins a layer's book and colour name, used by the Document scan and the panel's current colour instead of two copies. - Recently-used sorting looks a colour's rank up once (sort_by_cached_key) instead of on every comparison. - Searching compares case-insensitively without allocating a lower-case copy of every name on each redraw (ASCII queries). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/update/mod.rs | 11 +-- src/ui/color_select/document.rs | 38 +++++----- src/ui/color_select/picker.rs | 127 ++++++++++++++++++++------------ 3 files changed, 101 insertions(+), 75 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 27f2237b7..eee1f3f01 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -12081,13 +12081,10 @@ impl OpenCADStudio { return (color, false, self.ribbon.active_color_name()); } crate::app::ColorPickTarget::Layer(layer) => { - let name = document.layers.get(layer).and_then(|layer| { - match (&layer.book_name, &layer.color_name) { - (Some(book), Some(name)) => Some(format!("{book}${name}")), - (None, Some(name)) => Some(name.clone()), - _ => None, - } - }); + let name = document + .layers + .get(layer) + .and_then(crate::ui::color_select::document::layer_identity); return (color, false, name); } _ => return (color, false, None), diff --git a/src/ui/color_select/document.rs b/src/ui/color_select/document.rs index c257f9c1b..df024e6a4 100644 --- a/src/ui/color_select/document.rs +++ b/src/ui/color_select/document.rs @@ -89,19 +89,20 @@ fn key(color: AcadColor) -> Option { } } +/// 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 { - // Layers keep book and colour name apart; join them like objects do. - let layer_names: Vec> = document - .layers - .iter() - .map(|layer| match (&layer.book_name, &layer.color_name) { - (Some(book), Some(name)) => Some(format!("{book}${name}")), - (None, Some(name)) => Some(name.clone()), - _ => None, - }) - .collect(); + let 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) @@ -139,16 +140,15 @@ fn tally<'a>(uses: impl Iterator, Handle)>) - /// Sort for display. `recent` is this drawing's pick history, newest first; /// colours not picked this session follow, newest object first. pub fn sort(colors: &mut [DocColor], by: DocSort, recent: &[AcadColor]) { - let rank = |c: &DocColor| { - recent - .iter() - .position(|&r| r == c.color) - .unwrap_or(usize::MAX) - }; match by { - DocSort::Recent => { - colors.sort_by(|a, b| rank(a).cmp(&rank(b)).then(b.newest.cmp(&a.newest))) - } + // 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(); diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index f2421bfb7..40f003ef4 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -180,19 +180,43 @@ impl ColorPicker { self.status = Some(status); } - /// Turn to the page holding `BOOK$NAME`. False when no loaded book has it. - fn show_identity(&mut self, identity: &str) -> bool { + /// 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 false; + 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 true; + return; } } - false + } + + /// 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. @@ -275,22 +299,32 @@ impl ColorPicker { self.set_pending(AcadColor::Index(next)); } } + // Only over the colours on screen, so a search is respected. (ColorPickerTab::Document, _) => { - let at = self - .doc - .iter() - .position(|c| c.color == self.pending && c.name == self.pending_name); - if let Some(next) = step_list(at, self.doc.len(), DOC_COLUMNS, direction) { - self.update(ColorPickerMsg::PickDocument(next)); + 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, _) => { - let len = self - .books - .get(self.book) - .map_or(0, |book| book.colors.len()); - if let Some(next) = step_list(self.book_selection(), len, 1, direction) { - self.pick_book(next); + // 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]); } } _ => {} @@ -627,12 +661,10 @@ impl ColorPicker { .into(); } // Indices stay those of `doc`, which the pick messages refer to. - let query = self.doc_query.trim().to_lowercase(); let shown: Vec<(usize, &DocColor)> = self - .doc - .iter() - .enumerate() - .filter(|(_, entry)| query.is_empty() || entry.label().to_lowercase().contains(&query)) + .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())] @@ -787,30 +819,16 @@ impl ColorPicker { // The page's colours, or the first matches while searching. let selected = self.book_selection(); - let query = self.book_query.trim().to_lowercase(); - let mut bars = column![].spacing(2).height(Length::Fill); - let mut shown = 0; - if query.is_empty() { - let (start, colors) = book.page(self.book_page); - for (i, color) in colors.iter().enumerate() { - bars = bars.push(book_bar(start + i, color, selected)); - shown += 1; - } - } else { - for (i, color) in book - .colors - .iter() - .enumerate() - .filter(|(_, color)| color.name.to_lowercase().contains(&query)) - .take(PAGE_BARS) - { - bars = bars.push(book_bar(i, color, selected)); - shown += 1; - } - } - let bars: Element<'_, Message> = if shown == 0 { + 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, .. } @@ -824,7 +842,7 @@ impl ColorPicker { let strip = (0..book.page_count()).fold(column![], |strip, page| { let (_, colors) = book.page(page); let fill = rgb_color(colors[colors.len() / 2].rgb); - let shown = query.is_empty() && page == self.book_page; + let shown = !searching && page == self.book_page; strip.push( button(Space::new()) .on_press(picker_msg(ColorPickerMsg::BookPage(page))) @@ -1054,14 +1072,25 @@ fn picker_msg(msg: ColorPickerMsg) -> Message { Message::ColorPicker(msg) } -fn ink_is_dark(color: Color) -> bool { - let [r, g, b, _] = color.into_rgba8(); - math::ink_is_dark(r, g, b) +/// 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 { - if ink_is_dark(fill) { + let [r, g, b, _] = fill.into_rgba8(); + if math::ink_is_dark(r, g, b) { Color::from_rgb8(0x11, 0x12, 0x14) } else { Color::WHITE From c71a874fe0d21f2ddf627002552f683302c2923d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:38:00 +0000 Subject: [PATCH 29/32] Select Color: reuse helpers instead of repeated code - book::add_or_replace adds a colour book or replaces one of the same name, for both reading the folders and loading a file. - style.rs: the Plot Style and View Background colour targets use math::color_rgb (and hex_string) instead of their own conversions. - Ribbon: one layer_color lookup for Ribbon::active_layer_color and the ribbon's ByLayer swatch. - Picker: one swatch() for the Index and Document swatches, fill_of() for "colour or gray" and divider() for the 1 px separators. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/update/mod.rs | 20 ++---- src/app/update/style.rs | 24 +++----- src/ui/color_select/book.rs | 14 +++++ src/ui/color_select/picker.rs | 113 ++++++++++++++++++++-------------- src/ui/ribbon/mod.rs | 13 ++-- src/ui/ribbon/widgets.rs | 6 +- 6 files changed, 104 insertions(+), 86 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index eee1f3f01..cd8f968d0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -12004,17 +12004,10 @@ impl OpenCADStudio { } Message::ColorBookLoaded(Ok(book)) => { // A book loaded again replaces the earlier copy. - let books = std::sync::Arc::make_mut(&mut self.color_books); - let index = match books.iter().position(|b| b.name == book.name) { - Some(index) => { - books[index] = book; - index - } - None => { - books.push(book); - books.len() - 1 - } - }; + 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); } @@ -12162,10 +12155,7 @@ impl OpenCADStudio { .filter_map(|path| std::fs::read_to_string(path).ok()) .filter_map(|text| book::parse(&text).ok()) { - match books.iter_mut().find(|b| b.name == loaded.name) { - Some(same) => *same = loaded, - None => books.push(loaded), - } + book::add_or_replace(&mut books, loaded); } self.color_books = std::sync::Arc::new(books); } diff --git a/src/app/update/style.rs b/src/app/update/style.rs index b1cd0da91..b3daf77d8 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1171,14 +1171,8 @@ impl OpenCADStudio { ) { 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(); @@ -1191,15 +1185,11 @@ impl OpenCADStudio { ))) } 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( diff --git a/src/ui/color_select/book.rs b/src/ui/color_select/book.rs index 6a9a74f2f..cd2009ca1 100644 --- a/src/ui/color_select/book.rs +++ b/src/ui/color_select/book.rs @@ -57,6 +57,20 @@ impl ColorBook { } } +/// 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 { diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index 40f003ef4..8c0333820 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -434,28 +434,22 @@ impl ColorPicker { (None, None) => math::describe(self.pending), }; let error = self.status.is_some(); - body = body - .push( + body = body.push(divider()).push( + row![ container(Space::new()) - .width(Length::Fill) - .height(1) - .style(|theme: &Theme| filled(theme.palette().background.weak.color)), - ) - .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), - ); + .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) @@ -590,14 +584,14 @@ impl ColorPicker { let section = |label: String| text(label).size(11); let swatches = |range: std::ops::RangeInclusive| { range.fold(row![].spacing(5), |line, index| { - line.push(self.swatch_button(index, 30.0, 26.0)) + 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.swatch_button(family * 10 + shade, 13.0, 13.0)) + line.push(self.index_swatch(family * 10 + shade, 13.0, 13.0)) })) }); column![ @@ -617,14 +611,16 @@ impl ColorPicker { .into() } - fn swatch_button(&self, index: u8, width: f32, height: f32) -> Element<'_, Message> { - let fill = rgb_color(math::aci_rgb(index).unwrap_or((128, 128, 128))); - let selected = self.pending == AcadColor::Index(index); - button(Space::new().width(width).height(height)) - .on_press(picker_msg(ColorPickerMsg::Pick(AcadColor::Index(index)))) - .padding(0) - .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) - .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> { @@ -675,8 +671,7 @@ impl ColorPicker { shown .iter() .fold(column![].spacing(2), |list, &(i, entry)| { - let fill = math::color_rgb(entry.color) - .map_or(Color::from_rgb8(128, 128, 128), rgb_color); + let fill = fill_of(entry.color); let selected = self.pending == entry.color && self.pending_name == entry.name; list.push( color_bar( @@ -747,15 +742,13 @@ impl ColorPicker { } fn doc_swatch<'a>(&self, i: usize, entry: &'a DocColor) -> Element<'a, Message> { - let fill = math::color_rgb(entry.color).map_or(Color::from_rgb8(128, 128, 128), rgb_color); - let selected = self.pending == entry.color && self.pending_name == entry.name; - button(Space::new()) - .on_press(picker_msg(ColorPickerMsg::PickDocument(i))) - .padding(0) - .width(Length::Fill) - .height(30) - .style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected)) - .into() + 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> { @@ -915,10 +908,7 @@ impl ColorPicker { channel(t!("Red"), chosen.map(|c| c.rgb.0)), channel(t!("Green"), chosen.map(|c| c.rgb.1)), channel(t!("Blue"), chosen.map(|c| c.rgb.2)), - container(Space::new()) - .width(Length::Fill) - .height(1) - .style(|theme: &Theme| filled(theme.palette().background.weak.color)), + divider(), text(format!("{} / {}", self.book_page + 1, book.page_count())) .size(11) .style(muted_text), @@ -1185,6 +1175,37 @@ fn tab_icon<'a>(handle: svg::Handle) -> svg::Svg<'a> { }) } +/// 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)) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 2172218cb..7504828d1 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -148,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 @@ -188,10 +196,7 @@ 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 { - self.layer_infos - .iter() - .find(|layer| layer.name == self.active_layer) - .map(|layer| layer.color) + layer_color(&self.layer_infos, &self.active_layer) } /// The colour-book identity of the active colour, while the active colour diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 41b81ac71..a141408a7 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -1174,11 +1174,9 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen }; // ByLayer shows the current layer's colour. - let color_swatch = layer_infos - .iter() - .find(|layer| layer.name == active_layer) + let color_swatch = super::layer_color(layer_infos, active_layer) .filter(|_| active_color == AcadColor::ByLayer) - .map_or_else(|| acad_color_display(active_color).0, |layer| layer.color); + .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, From a9bbb416719c776c9ca68394ae9cde7989a056c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:53:58 +0000 Subject: [PATCH 30/32] Select Color: colour book loading and folder reading live in book.rs book::load_from_dialog (pick, read, copy into the first folder) and book::read_all (built-in book plus every .acb in the folders) move out of update/mod.rs, which now just calls them. Same behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/update/mod.rs | 67 +++++-------------------------------- src/ui/color_select/book.rs | 55 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 58 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index cd8f968d0..58b8867bc 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11934,38 +11934,12 @@ impl OpenCADStudio { Some(crate::ui::color_select::Outcome::Commit(color, name)) => { self.update(Message::ColorWindowPick(color, name)) } - Some(crate::ui::color_select::Outcome::LoadBook) => { - // Copied into the first colour book folder so it stays - // when the original moves; the web keeps it for the - // session only. - let folder = self.color_book_dirs.first().cloned(); - Task::perform( - async move { - 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 = crate::ui::color_select::book::parse( - &String::from_utf8_lossy(&bytes), - )?; - #[cfg(not(target_arch = "wasm32"))] - if let Some(folder) = folder { - let folder = std::path::PathBuf::from(folder); - std::fs::create_dir_all(&folder) - .and_then(|()| { - std::fs::write(folder.join(file.file_name()), &bytes) - }) - .map_err(|e| e.to_string())?; - } - #[cfg(target_arch = "wasm32")] - let _ = folder; - Ok(book) - }, - Message::ColorBookLoaded, - ) - } + Some(crate::ui::color_select::Outcome::LoadBook) => Task::perform( + crate::ui::color_select::book::load_from_dialog( + self.color_book_dirs.first().cloned(), + ), + Message::ColorBookLoaded, + ), Some(crate::ui::color_select::Outcome::Cancel) => { self.color_picker = None; Task::none() @@ -12132,32 +12106,9 @@ impl OpenCADStudio { if std::mem::replace(&mut self.color_books_read, true) { return; } - use crate::ui::color_select::book; - #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] - let mut books: Vec<_> = book::builtin().collect(); - #[cfg(not(target_arch = "wasm32"))] - for loaded in self - .color_book_dirs - .iter() - .flat_map(|dir| { - 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(); - files - }) - .filter_map(|path| std::fs::read_to_string(path).ok()) - .filter_map(|text| book::parse(&text).ok()) - { - book::add_or_replace(&mut books, loaded); - } - self.color_books = std::sync::Arc::new(books); + self.color_books = std::sync::Arc::new(crate::ui::color_select::book::read_all( + &self.color_book_dirs, + )); } /// Colour the selected objects (or set the current colour when nothing diff --git a/src/ui/color_select/book.rs b/src/ui/color_select/book.rs index cd2009ca1..4a8966403 100644 --- a/src/ui/color_select/book.rs +++ b/src/ui/color_select/book.rs @@ -77,6 +77,61 @@ pub fn builtin() -> impl Iterator { parse(include_str!("../../../assets/color_books/ral-classic.acb")).into_iter() } +/// The built-in book plus every `.acb` file in `dirs`, folder by folder in +/// file-name order. A book replaces an earlier one of the same name. +pub fn read_all(dirs: &[String]) -> Vec { + #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] + let mut books: Vec<_> = builtin().collect(); + // Browsers have no folders. + #[cfg(target_arch = "wasm32")] + let _ = dirs; + #[cfg(not(target_arch = "wasm32"))] + for book in dirs + .iter() + .flat_map(|dir| { + 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(); + files + }) + .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 +/// `folder`, the first 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(folder: Option) -> 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 { + let folder = std::path::PathBuf::from(folder); + std::fs::create_dir_all(&folder) + .and_then(|()| std::fs::write(folder.join(file.file_name()), &bytes)) + .map_err(|e| e.to_string())?; + } + #[cfg(target_arch = "wasm32")] + let _ = folder; + 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}')) From a9d8d4b531fc02a8ad6cd6af9657025df01060a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:09:26 +0000 Subject: [PATCH 31/32] Options: color book folder shown like the other locations Options > Files lists the color book folder as a read-only row with Open folder, like Plot styles or Fonts. The Add folder / Remove buttons, their messages and the explanatory line (in all 22 locales) are gone; the folder list stays in the settings for a later location option. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- locales/ar-SA/opencadstudio.ftl | 1 - locales/bg-BG/opencadstudio.ftl | 1 - locales/cs-CZ/opencadstudio.ftl | 1 - locales/de-DE/opencadstudio.ftl | 1 - locales/el-GR/opencadstudio.ftl | 1 - locales/en-US/opencadstudio.ftl | 1 - locales/es-ES/opencadstudio.ftl | 1 - locales/fi-FI/opencadstudio.ftl | 1 - locales/fr-FR/opencadstudio.ftl | 1 - locales/hi-IN/opencadstudio.ftl | 1 - locales/hu-HU/opencadstudio.ftl | 1 - locales/it-IT/opencadstudio.ftl | 1 - locales/ja-JP/opencadstudio.ftl | 1 - locales/ko-KR/opencadstudio.ftl | 1 - locales/lo-LA/opencadstudio.ftl | 1 - locales/nl-NL/opencadstudio.ftl | 1 - locales/pl-PL/opencadstudio.ftl | 1 - locales/pt-BR/opencadstudio.ftl | 1 - locales/ru-RU/opencadstudio.ftl | 1 - locales/tr-TR/opencadstudio.ftl | 1 - locales/zh-CN/opencadstudio.ftl | 1 - locales/zh-TW/opencadstudio.ftl | 1 - src/app/mod.rs | 4 --- src/app/update/mod.rs | 29 ------------------- src/locale_catalog.rs | 1 - src/ui/window/options.rs | 51 +++++---------------------------- 26 files changed, 7 insertions(+), 100 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 451abc9f4..22015f542 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1453,7 +1453,6 @@ common = .color-book-encrypted = كتاب الألوان هذا مشفر ولا يمكن قراءته .color-book-empty = لا يحتوي كتاب الألوان على ألوان .color-book-locations = مواقع كتب الألوان - .color-book-locations-hint = المجلدات التي يُبحث فيها عن كتب الألوان ‎.acb. يُنسخ الكتاب الذي يُحمَّل في اختيار اللون إلى المجلد الأول. .cad-color-index-aci-10-249 = فهرس ألوان ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = الرسم متساوي القياس diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index c5000d365..6dd6269cb 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1440,7 +1440,6 @@ common = .color-book-encrypted = Този цветови каталог е шифрован и не може да бъде прочетен .color-book-empty = Цветовият каталог няма цветове .color-book-locations = Местоположения на цветови каталози - .color-book-locations-hint = Папки, в които се търсят цветови каталози .acb. Каталог, зареден в „Избор на цвят“, се копира в първата папка. .cad-color-index-aci-10-249 = ACI Цветови индекс 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Изометрично чертане diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 94a425dc6..e596d1aee 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1440,7 +1440,6 @@ common = .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ů - .color-book-locations-hint = Složky, ve kterých se hledají barevné vzorníky .acb. Vzorník načtený ve Výběru barvy se zkopíruje do první složky. .cad-color-index-aci-10-249 = Barevný index ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Izometrické kreslení diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 4939d5334..e6b19a788 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1441,7 +1441,6 @@ common = .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 - .color-book-locations-hint = Ordner, in denen nach .acb-Farbbüchern gesucht wird. Ein in „Farbe wählen“ geladenes Farbbuch wird in den ersten Ordner kopiert. .cad-color-index-aci-10-249 = ACI Farbindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrisches Zeichnen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 1e4a496ba..e911acacf 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1442,7 +1442,6 @@ common = .color-book-encrypted = Αυτό το βιβλίο χρωμάτων είναι κρυπτογραφημένο και δεν μπορεί να διαβαστεί .color-book-empty = Το βιβλίο χρωμάτων δεν έχει χρώματα .color-book-locations = Θέσεις βιβλίων χρωμάτων - .color-book-locations-hint = Φάκελοι στους οποίους αναζητούνται βιβλία χρωμάτων .acb. Ένα βιβλίο που φορτώνεται στην Επιλογή χρώματος αντιγράφεται στον πρώτο φάκελο. .cad-color-index-aci-10-249 = Δείκτης χρώματος ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Ισομετρική σχεδίαση diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 15c13a19e..b1627244a 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1441,7 +1441,6 @@ common = .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 - .color-book-locations-hint = Folders searched for .acb color books. A book loaded in Select Color is copied into the first folder. .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .grayscale-250-255 = Grayscale 250–255 .isometric-drafting = Isometric drafting diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 385dc7148..a0796190e 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1441,7 +1441,6 @@ common = .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 - .color-book-locations-hint = Carpetas en las que se buscan libros de colores .acb. Un libro cargado en Seleccionar color se copia en la primera carpeta. .cad-color-index-aci-10-249 = Índice de color ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Dibujo isométrico diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index e583fe8de..b8789c11e 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1440,7 +1440,6 @@ common = .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 - .color-book-locations-hint = Kansiot, joista etsitään .acb-värikirjoja. Värin valinnassa ladattu värikirja kopioidaan ensimmäiseen kansioon. .cad-color-index-aci-10-249 = ACI väriindeksi 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrinen piirtäminen diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 6a90f77b9..276925a50 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1441,7 +1441,6 @@ common = .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 - .color-book-locations-hint = Dossiers où rechercher les carnets de couleurs .acb. Un carnet chargé dans Sélectionner une couleur est copié dans le premier dossier. .cad-color-index-aci-10-249 = ACI Indice de couleur 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Dessin isométrique diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 7853fb838..401153b46 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1429,7 +1429,6 @@ common = .color-book-encrypted = यह रंग पुस्तक एन्क्रिप्टेड है और पढ़ी नहीं जा सकती .color-book-empty = रंग पुस्तक में कोई रंग नहीं है .color-book-locations = रंग पुस्तकों के स्थान - .color-book-locations-hint = वे फ़ोल्डर जिनमें .acb रंग पुस्तकें खोजी जाती हैं। रंग चुनें में लोड की गई पुस्तक पहले फ़ोल्डर में कॉपी की जाती है। .cad-color-index-aci-10-249 = ACI रंग सूचकांक 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = सममितीय आरेखन diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 772b27ef8..ddbfebb3d 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1445,7 +1445,6 @@ common = .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 - .color-book-locations-hint = Mappák, amelyekben a program .acb színkönyveket keres. A Szín kiválasztása ablakban betöltött színkönyv az első mappába másolódik. .cad-color-index-aci-10-249 = ACI színindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Izometrikus rajzolás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index c79257d90..a3d3a4b43 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1440,7 +1440,6 @@ common = .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 - .color-book-locations-hint = Cartelle in cui cercare gli album colori .acb. Un album caricato in Seleziona colore viene copiato nella prima cartella. .cad-color-index-aci-10-249 = ACI Indice colori 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Disegno isometrico diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index dd7a1f238..425634dfd 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1431,7 +1431,6 @@ common = .color-book-encrypted = このカラー ブックは暗号化されているため読み込めません .color-book-empty = カラー ブックに色がありません .color-book-locations = カラー ブックの場所 - .color-book-locations-hint = .acb カラー ブックを検索するフォルダです。[色を選択] で読み込んだブックは最初のフォルダにコピーされます。 .cad-color-index-aci-10-249 = ACI カラーインデックス 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = アイソメ図作成 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 8bfb9fc12..54225f7d6 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1440,7 +1440,6 @@ common = .color-book-encrypted = 이 색상표는 암호화되어 있어 읽을 수 없습니다 .color-book-empty = 색상표에 색상이 없습니다 .color-book-locations = 색상표 위치 - .color-book-locations-hint = .acb 색상표를 찾는 폴더입니다. 색상 선택에서 로드한 색상표는 첫 번째 폴더에 복사됩니다. .cad-color-index-aci-10-249 = ACI 컬러 인덱스 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 등각 제도 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index b7746219a..06dc6ead2 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1446,7 +1446,6 @@ common = .color-book-encrypted = ປຶ້ມສີນີ້ຖືກເຂົ້າລະຫັດ ແລະອ່ານບໍ່ໄດ້ .color-book-empty = ປຶ້ມສີບໍ່ມີສີ .color-book-locations = ທີ່ຕັ້ງປຶ້ມສີ - .color-book-locations-hint = ໂຟນເດີທີ່ໃຊ້ຊອກຫາປຶ້ມສີ .acb. ປຶ້ມທີ່ໂຫຼດໃນເລືອກສີຈະຖືກສຳເນົາໄປໃສ່ໂຟນເດີທຳອິດ. .cad-color-index-aci-10-249 = CAD Color Index (ACI) 10–249 .grayscale-250-255 = ລະດັບສີເທົາ 250–255 .isometric-drafting = ການຂຽນແບບໄອໂຊເມຕຣິກ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 7b8c9aa3b..67df31ca5 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1439,7 +1439,6 @@ common = .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 - .color-book-locations-hint = Mappen waarin naar .acb-kleurboeken wordt gezocht. Een kleurboek dat in Kleur selecteren wordt geladen, wordt naar de eerste map gekopieerd. .cad-color-index-aci-10-249 = ACI Kleurindex 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Isometrisch tekenen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 732b758ec..0827e6118 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1440,7 +1440,6 @@ common = .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 - .color-book-locations-hint = Foldery przeszukiwane w poszukiwaniu wzorników kolorów .acb. Wzornik wczytany w oknie Wybierz kolor jest kopiowany do pierwszego folderu. .cad-color-index-aci-10-249 = Indeks kolorów ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Rysowanie izometryczne diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 9c28f9dc1..076d926ab 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1441,7 +1441,6 @@ common = .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 - .color-book-locations-hint = Pastas pesquisadas em busca de catálogos de cores .acb. Um catálogo carregado em Selecionar cor é copiado para a primeira pasta. .cad-color-index-aci-10-249 = Índice de cor ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Desenho isométrico diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index d60caaeee..6ad3ea35c 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1444,7 +1444,6 @@ common = .color-book-encrypted = Этот альбом цветов зашифрован и не может быть прочитан .color-book-empty = В альбоме цветов нет цветов .color-book-locations = Расположение альбомов цветов - .color-book-locations-hint = Папки, в которых ищутся альбомы цветов .acb. Альбом, загруженный в окне «Выбор цвета», копируется в первую папку. .cad-color-index-aci-10-249 = Индекс цвета ACI 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = Изометрическое черчение diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 6d45a347c..e99b763b8 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1427,7 +1427,6 @@ common = .color-book-encrypted = Bu renk kitabı şifreli ve okunamıyor .color-book-empty = Renk kitabında renk yok .color-book-locations = Renk kitabı konumları - .color-book-locations-hint = Bu klasörlerde .acb renk kitapları aranır. Renk Seç'te yüklenen bir kitap ilk klasöre kopyalanır. .cad-color-index-aci-10-249 = ACI renk dizini 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = İzometrik çizim diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 925cc8f1c..3c4b84316 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1428,7 +1428,6 @@ common = .color-book-encrypted = 此配色系统已加密,无法读取 .color-book-empty = 配色系统中没有颜色 .color-book-locations = 配色系统位置 - .color-book-locations-hint = 搜索 .acb 配色系统的文件夹。在“选择颜色”中加载的配色系统会复制到第一个文件夹。 .cad-color-index-aci-10-249 = ACI 颜色索引 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 等轴测绘图 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index bedcb7edc..73dc49f14 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1440,7 +1440,6 @@ common = .color-book-encrypted = 此配色系統已加密,無法讀取 .color-book-empty = 配色系統中沒有顏色 .color-book-locations = 配色系統位置 - .color-book-locations-hint = 搜尋 .acb 配色系統的資料夾。在「選取顏色」中載入的配色系統會複製到第一個資料夾。 .cad-color-index-aci-10-249 = ACI 顏色索引 10–249 .grayscale-250-255 = { common.grayscale } 250–255 .isometric-drafting = 等角製圖 diff --git a/src/app/mod.rs b/src/app/mod.rs index 47188882f..88d8acea4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4341,10 +4341,6 @@ pub enum Message { /// An `.acb` file was read (and, on desktop, copied into the first /// colour book folder). ColorBookLoaded(Result), - /// Options › Files: pick a folder to search for colour books. - ColorBookDirBrowse, - ColorBookDirAdd(String), - ColorBookDirRemove(usize), /// Set a block/linetype Handle field on the selected dim style from a /// dropdown of available block-records / linetypes (by name). DsSetHandle { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 58b8867bc..d0e3cad1d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11993,35 +11993,6 @@ impl OpenCADStudio { } Task::none() } - #[cfg(not(target_arch = "wasm32"))] - Message::ColorBookDirBrowse => Task::perform( - async { - crate::sys::file_dialog() - .pick_folder() - .await - .map(|handle| crate::sys::handle_path(&handle).display().to_string()) - }, - |dir| dir.map_or(Message::Noop, Message::ColorBookDirAdd), - ), - // Browsers expose no folder picker. - #[cfg(target_arch = "wasm32")] - Message::ColorBookDirBrowse => Task::none(), - Message::ColorBookDirAdd(dir) => { - if !self.color_book_dirs.contains(&dir) { - self.color_book_dirs.push(dir); - self.color_books_read = false; - self.persist_settings_if_changed(); - } - Task::none() - } - Message::ColorBookDirRemove(i) => { - if i < self.color_book_dirs.len() { - self.color_book_dirs.remove(i); - self.color_books_read = false; - self.persist_settings_if_changed(); - } - Task::none() - } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 92111d789..f7ad87303 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4945,7 +4945,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "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")), - "Folders searched for .acb color books. A book loaded in Select Color is copied into the first folder." => Some(("common", "color-book-locations-hint")), "CAD Color Index (ACI) 10–249" => Some(("common", "cad-color-index-aci-10-249")), "Grayscale 250–255" => Some(("common", "grayscale-250-255")), "Isometric drafting" => Some(("common", "isometric-drafting")), diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index ab7fb7776..61bedeff5 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -1773,49 +1773,6 @@ pub fn view_window<'a>( .align_y(iced::Center) }; - // Searched for `.acb` colour books; editable, unlike the folders above. - // Browsers have no folders, so the web build leaves it out. - let color_book_locations = |dirs: &'a [String]| -> Element<'a, Message> { - if cfg!(target_arch = "wasm32") { - return Space::new().into(); - } - let list = dirs - .iter() - .enumerate() - .fold(column![].spacing(6), |list, (i, dir)| { - list.push( - row![ - text(dir.as_str()).size(11).width(Fill), - button(text(crate::t!("Open folder")).size(11)) - .padding([4, 10]) - .style(button::secondary) - .on_press(Message::OpenFolder(dir.clone())), - button(text(crate::t!("Remove")).size(11)) - .padding([4, 10]) - .style(button::secondary) - .on_press(Message::ColorBookDirRemove(i)), - ] - .spacing(10) - .align_y(iced::Center), - ) - }); - column![ - text(crate::t!("Color book locations")).size(12), - text(crate::t!( - "Folders searched for .acb color books. A book loaded in Select Color is copied into the first folder." - )) - .size(11), - list, - button(text(crate::t!("Add folder…")).size(11)) - .padding([4, 10]) - .style(button::secondary) - .on_press(Message::ColorBookDirBrowse), - Space::new().height(16), - ] - .spacing(6) - .into() - }; - let files = column![ text(crate::t!("Files")).size(15), Space::new().height(6), @@ -1834,8 +1791,14 @@ 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()), + // Where `.acb` colour books are found (none on the web). + color_book_dirs.iter().fold(column![], |rows, dir| rows + .push(Space::new().height(12)) + .push(folder_row( + crate::t!("Color book locations"), + Some(dir.clone()) + ))), Space::new().height(16), - color_book_locations(color_book_dirs), text(crate::t!("Default template for new drawings")).size(12), Space::new().height(4), row![ From 6e05c7e2e5337ef4c0d1bfd80c1149260756ebdc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:15:53 +0000 Subject: [PATCH 32/32] Select Color: one fixed colour book folder instead of a folder list setting The color_book_dirs setting is gone. Colour books live in color_books in the config folder (book::folder()), like the other fixed locations; Options > Files shows it through Folders::color_books. read_all() and load_from_dialog() use that folder directly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/mod.rs | 6 ++-- src/app/settings.rs | 14 --------- src/app/update/file.rs | 5 --- src/app/update/mod.rs | 14 +++------ src/app/view/modal.rs | 3 +- src/ui/color_select/book.rs | 62 ++++++++++++++++++------------------- src/ui/window/options.rs | 15 +++++---- 7 files changed, 47 insertions(+), 72 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 88d8acea4..fe90c4e7a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1067,10 +1067,9 @@ pub(super) struct OpenCADStudio { // ── Floating panel windows ──────────────────────────────────────────── /// The open Select Color panel, if any. color_picker: Option, - /// The built-in colour books and those in `color_book_dirs`, read - /// lazily when the Select Color panel opens. + /// 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_book_dirs: Vec, color_books_read: bool, /// Recently committed real colours, newest first. /// ACI and True Color remain semantically distinct even when their RGB matches. @@ -4793,7 +4792,6 @@ impl OpenCADStudio { web_autosave_clear_after: Vec::new(), color_picker: None, color_books: Default::default(), - color_book_dirs: Vec::new(), color_books_read: false, active_modal: None, hyperlink_editor_handles: Vec::new(), diff --git a/src/app/settings.rs b/src/app/settings.rs index 5a2d5b350..a63453fb1 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -636,10 +636,6 @@ pub struct UserSettings { /// Blocks palette: library folders or drawings, last used first. #[serde(default)] pub block_libraries: Vec, - /// Folders searched for `.acb` colour books. Books loaded in the Select - /// Color panel are copied into the first one. - #[serde(default = "default_color_book_dirs")] - pub color_book_dirs: Vec, /// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons, /// 4 details, 5 list. #[serde(default = "default_block_view")] @@ -801,15 +797,6 @@ fn default_grid_major() -> u32 { 5 } -/// One folder in the config directory; none on the web, which has no folders. -fn default_color_book_dirs() -> Vec { - #[cfg(not(target_arch = "wasm32"))] - if let Some(dir) = crate::config::config_dir() { - return vec![dir.join("color_books").display().to_string()]; - } - Vec::new() -} - fn default_true() -> bool { true } @@ -973,7 +960,6 @@ impl Default for UserSettings { block_recent: Vec::new(), block_favorites: Vec::new(), block_libraries: Vec::new(), - color_book_dirs: default_color_book_dirs(), block_palette_view: 1, block_insert: BlockInsertOptions::default(), block_mru_list: 50, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index a3ef47a36..5405968bc 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -868,7 +868,6 @@ impl OpenCADStudio { block_recent: self.block_palette.recent.clone(), block_favorites: self.block_palette.favorites.clone(), block_libraries: self.block_palette.libraries.clone(), - color_book_dirs: self.color_book_dirs.clone(), block_palette_view: self.block_palette.view as u8, block_insert: self.block_palette.options.clone(), block_mru_list: self.block_mru_list, @@ -1047,10 +1046,6 @@ impl OpenCADStudio { self.block_palette.recent = s.block_recent.clone(); self.block_palette.favorites = s.block_favorites.clone(); self.block_palette.libraries = s.block_libraries.clone(); - if self.color_book_dirs != s.color_book_dirs { - self.color_book_dirs = s.color_book_dirs.clone(); - self.color_books_read = false; - } self.block_palette.view = crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view); self.block_palette.set_options(s.block_insert.clone()); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d0e3cad1d..f26586359 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -11935,9 +11935,7 @@ impl OpenCADStudio { self.update(Message::ColorWindowPick(color, name)) } Some(crate::ui::color_select::Outcome::LoadBook) => Task::perform( - crate::ui::color_select::book::load_from_dialog( - self.color_book_dirs.first().cloned(), - ), + crate::ui::color_select::book::load_from_dialog(), Message::ColorBookLoaded, ), Some(crate::ui::color_select::Outcome::Cancel) => { @@ -12070,16 +12068,14 @@ impl OpenCADStudio { document.layers.get(layer).map(|layer| layer.color) } - /// Read the built-in colour books and every `.acb` in the colour book - /// folders, once until the folders change. A book in a folder replaces a - /// built-in one of the same name. + /// 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( - &self.color_book_dirs, - )); + 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 diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 12671c570..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")] @@ -381,7 +383,6 @@ impl OpenCADStudio { } }, &self.qnew_template, - &self.color_book_dirs, self.double_click_block_refedit, self.double_click_block_attedit, self.cursor_type, diff --git a/src/ui/color_select/book.rs b/src/ui/color_select/book.rs index 4a8966403..cff68b5b6 100644 --- a/src/ui/color_select/book.rs +++ b/src/ui/color_select/book.rs @@ -77,42 +77,45 @@ pub fn builtin() -> impl Iterator { parse(include_str!("../../../assets/color_books/ral-classic.acb")).into_iter() } -/// The built-in book plus every `.acb` file in `dirs`, folder by folder in +/// 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(dirs: &[String]) -> Vec { +pub fn read_all() -> Vec { #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut books: Vec<_> = builtin().collect(); - // Browsers have no folders. - #[cfg(target_arch = "wasm32")] - let _ = dirs; #[cfg(not(target_arch = "wasm32"))] - for book in dirs - .iter() - .flat_map(|dir| { - 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(); - files - }) - .filter_map(|path| std::fs::read_to_string(path).ok()) - .filter_map(|text| parse(&text).ok()) - { - add_or_replace(&mut books, book); + 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 -/// `folder`, the first 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(folder: Option) -> Result { +/// 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() @@ -121,14 +124,11 @@ pub async fn load_from_dialog(folder: Option) -> Result, + /// 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. @@ -212,7 +214,6 @@ pub fn view_window<'a>( drawing_prefs: DrawingPrefs, folders: Folders, qnew_template: &'a str, - color_book_dirs: &'a [String], double_click_block_refedit: bool, double_click_block_attedit: bool, cursor_type: CursorType, @@ -1791,13 +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()), - // Where `.acb` colour books are found (none on the web). - color_book_dirs.iter().fold(column![], |rows, dir| rows - .push(Space::new().height(12)) - .push(folder_row( - crate::t!("Color book locations"), - Some(dir.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),