From 00830f8714d5c5c2f9d45f15fd941c16dd7fb060 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:29:46 +0000 Subject: [PATCH] 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;