diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5de064d6b..03e7dc10e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -143,38 +143,11 @@ impl Color { } /// HSB with hue in degrees 0..360, saturation and brightness 0..1. pub fn to_hsb(&self) -> [f32; 3] { - let [r, g, b] = self.to_rgb(); - let max = r.max(g).max(b); - let min = r.min(g).min(b); - let d = max - min; - let h = if d <= 0.0 { - 0.0 - } else if max == r { - 60.0 * (((g - b) / d).rem_euclid(6.0)) - } else if max == g { - 60.0 * ((b - r) / d + 2.0) - } else { - 60.0 * ((r - g) / d + 4.0) - }; - // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. - let h = if h >= 360.0 { 0.0 } else { h }; - let s = if max <= 0.0 { 0.0 } else { d / max }; - [h, s, max] + rgb_to_hsb(self.to_rgb()) } pub fn from_hsb(h: f32, s: f32, v: f32) -> Self { - let h = h.rem_euclid(360.0) / 60.0; - let c = v * s; - let x = c * (1.0 - (h % 2.0 - 1.0).abs()); - let (r, g, b) = match h as u32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m = v - c; - Color::Rgb { r: r + m, g: g + m, b: b + m } + let [r, g, b] = hsb_to_rgb([h, s, v]); + Color::Rgb { r, g, b } } /// `#rrggbb` (display RGB). pub fn to_hex(&self) -> String { @@ -327,6 +300,45 @@ impl Color { } } +/// HSB (hue in degrees 0..360, saturation and brightness 0..1) of RGB numbers 0..1, with no color +/// conversion ([`Color::to_hsb`] reads the display color first). +pub fn rgb_to_hsb([r, g, b]: [f32; 3]) -> [f32; 3] { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / d).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. + let h = if h >= 360.0 { 0.0 } else { h }; + let s = if max <= 0.0 { 0.0 } else { d / max }; + [h, s, max] +} + +/// RGB numbers 0..1 of HSB `[h, s, v]` (hue in degrees, saturation and brightness 0..1): the inverse +/// of [`rgb_to_hsb`]. +pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let h = h.rem_euclid(360.0) / 60.0; + let c = v * s; + let x = c * (1.0 - (h % 2.0 - 1.0).abs()); + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = v - c; + [r + m, g + m, b + m] +} + /// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations /// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from. pub fn keep_model(orig: Color, new: Color) -> Color { diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index fdbf335cb..c4ef1a984 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -115,6 +115,23 @@ impl Channel { Channel::Blue => (2, 0, 1), } } + /// The color field's axes (across, up) as keys and screen readers see them: hue in degrees, + /// saturation and brightness in percent, red, green and blue from 0 to 255. + fn field_axes(self) -> [widgets::FieldAxis<'static>; 2] { + let axis = |i: usize| { + let (name, units, suffix) = match (self.is_hsb(), i) { + (true, 0) => (tl!("Hue"), 360.0, "°"), + (true, 1) => (tl!("Saturation"), 100.0, "%"), + (true, _) => (tl!("Brightness"), 100.0, "%"), + (false, 0) => (tl!("Red"), 255.0, ""), + (false, 1) => (tl!("Green"), 255.0, ""), + (false, _) => (tl!("Blue"), 255.0, ""), + }; + widgets::FieldAxis { name, units, suffix } + }; + let (_, x, y) = self.axes(); + [axis(x), axis(y)] + } } /// The picked colour with its HSB (hue in degrees), kept so the hue survives grey colours. @@ -367,7 +384,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction { } else { let (z, x, y) = field_pos(channel, &p); let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color); - if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(side, side), (x, y), &at) { + if let (Some((x, y)), _) = + widgets::color_field(ui, "cp-field", (tl!("Color field"), channel.field_axes()), vec2(side, side), (x, y), true, &at) + { pick = Some(field_pick(channel, z, x, y)); } ui.add_space(SLIDER_GAP); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e4caf74f5..5076822d8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -551,6 +551,11 @@ font-technology Variable Променливи Color Table ({n} colors) Цветова таблица ({n} цвята) Color Theme Цветова тема Color Themes Цветови теми + Color Wheel Цветно колело + Hue ring Пръстен на оттенъка + Saturation and brightness Насищане и яркост + Color field Цветово поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Цветен тип: Color: Цвят: Colorize Оцветяване diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 79a29a2ac..92ab26279 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -696,6 +696,11 @@ Color Boja Color Guide Vodič za boje Color Themes Teme boja + Color Wheel Kotač boja + Hue ring Prsten nijanse + Saturation and brightness Zasićenje i svjetlina + Color field Polje boje + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS svojstva Document Info Informacije o dokumentu Flattener Preview Pregled spljoštavanja diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1eca3afd7..b9cbe4d2d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -766,6 +766,11 @@ antiAlias Sharp Nítid Color Table ({n} colors) Taula de colors ({n} colors) Color Theme Tema de colors Color Themes Temes de colors + Color Wheel Roda de colors + Hue ring Anell de to + Saturation and brightness Saturació i lluentor + Color field Camp de color + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipus de color: Color wheel Roda de color Color: Color: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d0ebe1b98..e9bb374db 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -705,6 +705,11 @@ Color Barva Color Guide Průvodce barvami Color Themes Barevné motivy + Color Wheel Barevné kolo + Hue ring Prstenec odstínů + Saturation and brightness Sytost a jas + Color field Barevné pole + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Vlastnosti CSS Document Info Informace o dokumentu Flattener Preview Náhled sloučení diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 43a134d27..ae7b04a20 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -654,6 +654,11 @@ Color Farve Color Guide Farveguide Color Themes Farvetemaer + Color Wheel Farvehjul + Hue ring Ring for farvetone + Saturation and brightness Mætning og lysstyrke + Color field Farvefelt + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-egenskaber Document Info Dokumentoplysninger Flattener Preview Udjævningsforhåndsvisning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3473d3949..3c9772960 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -544,6 +544,11 @@ font-technology Variable Variabel Color Table ({n} colors) Farbtabelle ({n} Farben) Color Theme Farbthema Color Themes Farbthemen + Color Wheel Farbkreis + Hue ring Farbtonring + Saturation and brightness Sättigung und Helligkeit + Color field Farbauswahlfeld + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Farbart: Color: Farbe: Colorize Einfärben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bd16c9945..2b0c2dd80 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -526,6 +526,11 @@ font-technology Variable Μεταβλητές Color Table ({n} colors) Πίνακας χρωμάτων ({n} χρώματα) Color Theme Χρωματικό θέμα Color Themes Χρωματικά θέματα + Color Wheel Χρωματικός τροχός + Hue ring Δακτύλιος απόχρωσης + Saturation and brightness Κορεσμός και φωτεινότητα + Color field Πεδίο χρώματος + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Τύπος χρώματος: Color: Χρώμα: Colorize Χρωματισμός diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2f5e88a34..5ff35f670 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -566,6 +566,11 @@ font-technology Variable Variables Color Table ({n} colors) Tabla de colores ({n} colores) Color Theme Tema de color Color Themes Temas de color + Color Wheel Rueda de color + Hue ring Anillo de tono + Saturation and brightness Saturación y brillo + Color field Campo de color + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo de color: Color: Color: Colorize Colorear diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a8c740638..9f1e97eb8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -696,6 +696,11 @@ Color Väri Color Guide Väriopas Color Themes Väriteemat + Color Wheel Väriympyrä + Hue ring Sävyrengas + Saturation and brightness Kylläisyys ja kirkkaus + Color field Värikenttä + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-ominaisuudet Document Info Asiakirjan tiedot Flattener Preview Litistyksen esikatselu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8c2716be6..a4ca110d8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -567,6 +567,11 @@ font-technology Variable Variables Color Table ({n} colors) Table des couleurs ({n} couleurs) Color Theme Thème de couleurs Color Themes Thèmes de couleurs + Color Wheel Roue chromatique + Hue ring Anneau de teinte + Saturation and brightness Saturation et luminosité + Color field Champ de couleur + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Type de couleur : Color: Couleur : Colorize Coloriser diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9820aa0ef..d84600a79 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -867,6 +867,11 @@ antiAlias Sharp शार्प Color Table (4 colors) रंग तालिका (4 रंग) Color Theme रंग थीम Color Themes रंग थीम्स + Color Wheel कलर व्हील + Hue ring ह्यू रिंग + Saturation and brightness सैचुरेशन और ब्राइटनेस + Color field कलर फ़ील्ड + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: रंग का प्रकार: Color wheel कलर व्हील Color: रंग: diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 556db2cd9..e53693633 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -742,6 +742,11 @@ font-technology Variable Változó Color Table ({n} colors) Színtábla ({n} szín) Color Theme Színtéma Color Themes Színtémák + Color Wheel Színkör + Hue ring Színárnyalat-gyűrű + Saturation and brightness Telítettség és fényesség + Color field Színmező + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Színtípus: Color: Szín: Colorization Színezés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 54e894885..0ca9c8c1c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -654,6 +654,11 @@ Color Warna Color Guide Panduan warna Color Themes Tema warna + Color Wheel Roda warna + Hue ring Cincin rona + Saturation and brightness Saturasi dan kecerahan + Color field Kolom warna + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Properti CSS Document Info Info dokumen Flattener Preview Pratinjau perata diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 427bc5903..04318dadd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -566,6 +566,11 @@ font-technology Variable Variabili Color Table ({n} colors) Tabella colori ({n} colori) Color Theme Tema colore Color Themes Temi colore + Color Wheel Ruota dei colori + Hue ring Anello tonalità + Saturation and brightness Saturazione e luminosità + Color field Campo colore + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo di colore: Color: Colore: Colorize Colora diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b4388179f..2c448954d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -714,6 +714,11 @@ digits Malayalam マラヤーラム数字 Color カラー Color Guide カラーガイド Color Themes カラーテーマ + Color Wheel カラーホイール + Hue ring 色相リング + Saturation and brightness 彩度と明るさ + Color field カラーフィールド + {name1} {value1}, {name2} {value2} {name1} {value1}、{name2} {value2} CSS Properties CSS プロパティ Document Info ドキュメント情報 Flattener Preview 分割・統合プレビュー diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 26a7f1a42..c1b140131 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -606,6 +606,11 @@ Color ფერი Color Guide ფერის გზამკვლევი Color Themes ფერის თემები + Color Wheel ფერთა წრე + Hue ring ფერის ტონის რგოლი + Saturation and brightness გაჯერება და სიკაშკაშე + Color field ფერის ველი + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS თვისებები Document Info დოკუმენტის ინფორმაცია Flattener Preview გაბრტყელების წინასწარი ნახვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7eb908459..df120ce22 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -707,6 +707,11 @@ digits Malayalam 말라얄람어 Color 색상 Color Guide 색상 가이드 Color Themes 색상 테마 + Color Wheel 색상환 + Hue ring 색조 링 + Saturation and brightness 채도 및 밝기 + Color field 색상 필드 + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS 속성 Document Info 문서 정보 Flattener Preview 병합 미리 보기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 81320e62c..9f4d8ccaf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -668,6 +668,11 @@ Color Kleur Color Guide Kleurhulp Color Themes Kleurthema's + Color Wheel Kleurenwiel + Hue ring Tintring + Saturation and brightness Verzadiging en helderheid + Color field Kleurveld + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-eigenschappen Document Info Documentinfo Flattener Preview Afvlakvoorbeeld diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f5a68f305..40084b30c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -654,6 +654,11 @@ Color Kolor Color Guide Przewodnik kolorów Color Themes Motywy kolorów + Color Wheel Koło kolorów + Hue ring Pierścień barwy + Saturation and brightness Nasycenie i jasność + Color field Pole koloru + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Właściwości CSS Document Info Informacje o dokumencie Flattener Preview Podgląd spłaszczania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 946bdeeba..36d5de552 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -511,6 +511,11 @@ antiAlias Sharp Nítido Color Swatches Amostras de Cor Color Table Tabela de Cores Color Themes Temas de Cores + Color Wheel Roda de Cores + Hue ring Anel de Tom + Saturation and brightness Saturação e Brilho + Color field Campo de Cor + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo de Cor: Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopia Color blindness – Protanopia-type Daltonismo – tipo protanopia diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e2db8213a..7b67e7b7f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -564,6 +564,11 @@ font-technology Variable Вариативные Color Table ({n} colors) Цветовая таблица ({n} цветов) Color Theme Цветовая тема Color Themes Цветовые темы + Color Wheel Цветовой круг + Hue ring Кольцо оттенков + Saturation and brightness Насыщенность и яркость + Color field Цветовое поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Тип цвета: Color: Цвет: Colorize Тонировать diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e17b35356..6b3334ad0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -553,6 +553,11 @@ font-technology Variable Variabla Color Table ({n} colors) Färgtabell ({n} färger) Color Theme Färgtema Color Themes Färgteman + Color Wheel Färghjul + Hue ring Nyansring + Saturation and brightness Mättnad och ljusstyrka + Color field Färgfält + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Färgtyp: Color: Färg: Colorize Färglägg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 13b3476f5..d106ef95a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -547,6 +547,11 @@ font-technology Variable Değişken Color Table ({n} colors) Renk Tablosu ({n} renkleri) Color Theme Renk Teması Color Themes Renk Temaları + Color Wheel Renk Tekerleği + Hue ring Renk tonu halkası + Saturation and brightness Doygunluk ve parlaklık + Color field Renk seçim alanı + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Renk Türü: Color: Renk: Colorize Renklendir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 87d14218c..84548ef71 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -560,6 +560,11 @@ font-technology Variable Варіативні Color Table ({n} colors) Таблиця кольорів ({n} кольорів) Color Theme Колірна тема Color Themes Колірні теми + Color Wheel Колірне коло + Hue ring Кільце відтінків + Saturation and brightness Насиченість і яскравість + Color field Колірне поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Тип кольору: Color: Колір: Colorize Тонувати diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c6bd47f63..3a5cc3e6a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -653,6 +653,11 @@ font-technology Variable Biến thiên Color Table ({n} colors) Bảng màu ({n} màu) Color Theme Bộ màu Color Themes Bộ màu + Color Wheel Bánh xe màu + Hue ring Vòng sắc độ + Saturation and brightness Độ bão hòa và độ sáng + Color field Vùng chọn màu + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Loại màu: Color: Màu: Colorization Tô màu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 47dc29b0e..2a2f75418 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -512,6 +512,11 @@ antiAlias Sharp 锐利 Color Table ({n} colors) 颜色表 ({n} 色) Color Theme 色彩主题 Color Themes 颜色主题 + Color Wheel 色轮 + Hue ring 色相环 + Saturation and brightness 饱和度和亮度 + Color field 颜色区域 + {name1} {value1}, {name2} {value2} {name1} {value1},{name2} {value2} Color Type: 颜色类型: Color: 颜色: Colorize 上色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a33f01c6a..b157d4fbb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -528,6 +528,11 @@ font-technology Variable 可變 Color Table ({n} colors) 顏色表 ({n} 色) Color Theme 色彩主題 Color Themes 色彩主題 + Color Wheel 色輪 + Hue ring 色相環 + Saturation and brightness 飽和度和亮度 + Color field 顏色區域 + {name1} {value1}, {name2} {value2} {name1} {value1},{name2} {value2} Color Type: 顏色類型: Color: 顏色: Colorize 上色 diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index da521390a..fdea9664b 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -644,6 +644,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Color", "color"), panel("Color Guide", "colorGuide"), panel("Color Themes", "colorThemes"), + panel("Color Wheel", crate::panels::color_wheel::ID), panel("CSS Properties", crate::panels::css_properties::ID), panel("Document Info", "docInfo"), panel("Flattener Preview", crate::panels::flattener_preview::ID), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 84eaf8791..2282633eb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -15,6 +15,7 @@ pub mod character; pub mod color; pub mod color_guide; pub mod color_themes; +pub mod color_wheel; pub mod css_properties; pub mod doc_info; pub mod flattener_preview; @@ -177,6 +178,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { svg_interactivity::ID => svg_interactivity::show(app, ui), variables::ID => variables::show(app, ui), "colorThemes" => color_themes::show(app, ui), + color_wheel::ID => color_wheel::show(app, ui), links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), css_properties::ID => css_properties::show(app, ui), @@ -655,6 +657,8 @@ mod tests_blend; #[cfg(test)] mod tests_brush_libraries; #[cfg(test)] +mod tests_color_wheel; +#[cfg(test)] mod tests_constrain; #[cfg(test)] mod tests_css_properties; diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 643a7ff66..e58ca56f6 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -354,7 +354,7 @@ fn to32(c: &Color) -> Color32 { } /// The global or spot swatch a colour is a tint of (Tint mode). -struct Tint { +pub(crate) struct Tint { swatch: String, spot: bool, /// 0..1. @@ -375,20 +375,20 @@ impl Tint { /// What the panel is editing: the active proxy's solid colour or a selected gradient stop (with /// the global swatch it is a tint of), or a selected freeform point. -enum Target { +pub(crate) enum Target { Paint(Option, Option), Stop { paint: Paint, index: usize, color: Color, tint: Option }, Point { index: usize, color: Color }, } impl Target { - fn color(&self) -> Option { + pub(crate) fn color(&self) -> Option { match self { Target::Paint(c, _) => *c, Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color), } } - fn tint(&self) -> Option<&Tint> { + pub(crate) fn tint(&self) -> Option<&Tint> { match self { Target::Paint(_, t) | Target::Stop { tint: t, .. } => t.as_ref(), Target::Point { .. } => None, @@ -425,9 +425,14 @@ fn target_with(app: &VectorcraftApp, mixed: bool) -> Target { } } +/// [`target`] for the Color Wheel panel, which edits what this panel's sliders edit. +pub(crate) fn wheel_target(app: &VectorcraftApp, ui: &Ui) -> Target { + target(app, ui) +} + /// Apply `c` to the target, or with `behind` (an Alt-click) to the inactive proxy as a solid /// colour, keeping the active proxy in front. -fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) { +pub(crate) fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) { match tgt { Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => { let mut stops = g.gradient.stops.clone(); diff --git a/crates/ui-egui/src/panels/color_wheel.rs b/crates/ui-egui/src/panels/color_wheel.rs new file mode 100644 index 000000000..a2108d8cf --- /dev/null +++ b/crates/ui-egui/src/panels/color_wheel.rs @@ -0,0 +1,166 @@ +//! Color Wheel panel (Window › Color Wheel): the Fill and Stroke proxy beside a hue ring around a +//! square with saturation across and brightness up at the ring's hue. The wheel edits what the +//! Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform +//! point) through the same paint commands, and a drag is one undo step. Alt paints the inactive +//! proxy and Shift steps the ring by 45°. A pick is display sRGB, converted into the working RGB +//! space: an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch +//! becomes a process color in the document's color mode. The HSB picked last stays while the +//! objects, the proxy, the color and the color settings are unchanged, so a gray keeps its hue. + +use egui::{Ui, UiBuilder}; +use vectorcraft_color::cms::{self, Cms}; +use vectorcraft_color::{Color, Paint, hsb_to_rgb, rgb_to_hsb}; +use vectorcraft_doc::{ColorMode, NodeId}; + +use super::color::{Target, apply, wheel_target}; +use super::{pstate, set_pstate}; +use crate::VectorcraftApp; +use crate::widgets::{self, FieldAxis, Live}; + +pub const ID: &str = "colorWheel"; +/// The widest the wheel is drawn. +const MAX_WHEEL: f32 = 200.0; +/// Panel state: the HSB picked last, its [`Owner`] and the display sRGB of the color it stored. +const MEMORY: &str = "color-wheel-hsb"; + +/// What the HSB picked last belongs to: the document, the selected objects or text characters, +/// the appearance item, the proxy, the gradient stop or freeform point, its paint, and the color +/// the pick stored. The wheel shows that HSB only while all of them are unchanged. +#[derive(Clone, Debug, PartialEq)] +struct Owner { + doc: Option, + selection: Vec, + /// The Type tool's selected characters, when it edits the paint. + text_selection: Option<(NodeId, usize, usize)>, + /// The Appearance panel's active fill or stroke row. + appearance_item: Option, + fill: bool, + /// The selected gradient stop (`Some((false, i))`) or freeform point (`Some((true, i))`). + slot: Option<(bool, usize)>, + /// The gradient behind a selected stop or point, including its other colors. + gradient: Option, + color: Color, +} + +impl Owner { + fn of(app: &VectorcraftApp, tgt: &Target, color: Color) -> Self { + let st = app.session.active(); + let slot = match tgt { + Target::Paint(..) => None, + Target::Stop { index, .. } => Some((false, *index)), + Target::Point { index, .. } => Some((true, *index)), + }; + Self { + doc: st.map(|d| d.uid), + selection: st.map(|d| d.selection.objects.clone()).unwrap_or_default(), + text_selection: super::character::text_editing(app), + appearance_item: app.session.appearance_item(), + fill: app.session.fill_active, + slot, + gradient: slot.map(|_| super::active_paint(app)), + color, + } + } +} + +/// The HSB a color shows on the wheel: that of its display sRGB through `cms`. +pub(crate) fn display_hsb(cms: &Cms, c: &Color) -> [f32; 3] { + rgb_to_hsb(cms.display_rgb(c)) +} + +/// The HSB the wheel shows for `c` through `cms` when `hsb` was picked last and `c` then displayed +/// as the sRGB `shown`: `hsb` while `c` still displays as `shown`, else `c`'s own display HSB (after +/// a change of the color settings or the document's profile). +pub(crate) fn remembered_hsb(cms: &Cms, c: &Color, shown: [f32; 3], hsb: [f32; 3]) -> [f32; 3] { + if cms.display_rgb(c) == shown { hsb } else { display_hsb(cms, c) } +} + +/// The color a pick of display HSB `hsb` stores in place of `was`, through `cms`. The picked sRGB +/// goes into the working RGB space. An RGB color stays RGB, and a CMYK or Lab color converts to its +/// model with the color settings. Anything else (no color, a Gray color, a tint of a global swatch) +/// becomes a process color in the document's color mode: managed CMYK when `cmyk_doc`, else RGB. +pub(crate) fn stored(cms: &Cms, hsb: [f32; 3], was: Option<&Color>, cmyk_doc: bool) -> Color { + let [r, g, b] = cms.srgb_to_rgb(hsb_to_rgb(hsb)); + let rgb = Color::rgb(r, g, b); + let cmyk = || { + let [c, m, y, k] = cms.to_cmyk(&rgb, cms.settings().intent); + Color::cmyk(c, m, y, k) + }; + match was { + Some(Color::Rgb { .. }) => rgb, + Some(Color::Cmyk { .. }) => cmyk(), + Some(Color::Lab { .. }) => { + let l = cms.lab(&rgb); + Color::lab(l.l, l.a, l.b) + } + _ if cmyk_doc => cmyk(), + _ => rgb, + } +} + +/// The color a pick replaces, which sets the model of the new one: the target's, or with Alt +/// (`behind`) the inactive proxy's solid color. None for a tint of a global swatch. +fn replaced(app: &VectorcraftApp, tgt: &Target, behind: bool) -> Option { + if !behind { + return tgt.color().filter(|_| tgt.tint().is_none()); + } + let (fill, stroke) = super::current_paints(app); + match if app.session.fill_active { stroke } else { fill } { + Paint::Solid { color, swatch, .. } => { + let global = swatch.is_some_and(|n| app.session.active().is_some_and(|d| d.doc.global_color(&n).is_some())); + (!global).then_some(color) + } + _ => None, + } +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let tgt = wheel_target(app, ui); + let color = tgt.color(); + let cms = cms::active(); + // With None, a pattern or a "?" proxy the wheel shows the last solid color without markers, + // and a click, an arrow key or a screen reader's action applies the picked color. + let hsb = match (color, pstate::>(ui.ctx(), MEMORY)) { + (Some(c), Some((owner, shown, hsb))) if owner == Owner::of(app, &tgt, c) => remembered_hsb(&cms, &c, shown, hsb), + (c, _) => display_hsb(&cms, &c.unwrap_or(app.session.last_solid)), + }; + let marked = color.is_some(); + let mut pick: Option<([f32; 3], Live)> = None; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(44.0); + super::proxy(app, ui, 40.0); + }); + let size = ui.available_width().min(MAX_WHEEL); + let ring = widgets::hue_ring(ui, "color-wheel-ring", tl!("Hue ring"), size, hsb[0], marked); + let square = widgets::ring_square(ring.rect); + let mut field_ui = ui.new_child(UiBuilder::new().id_salt("color-wheel-square").max_rect(square)); + let axes = + [FieldAxis { name: tl!("Saturation"), units: 100.0, suffix: "%" }, FieldAxis { name: tl!("Brightness"), units: 100.0, suffix: "%" }]; + let at = |x: f32, y: f32| widgets::hsb32([hsb[0], x, 1.0 - y]); + let name = tl!("Saturation and brightness"); + let (field, field_phase) = widgets::color_field(&mut field_ui, "field", (name, axes), square.size(), (hsb[1], 1.0 - hsb[2]), marked, &at); + if let Some((h, phase)) = ring.hue { + pick = Some(([h, hsb[1], hsb[2]], phase)); + } + // A press between the square and the ring picks the closest point of the square, which + // takes the keyboard focus. + if let Some((p, phase)) = ring.inner { + let field_id = widgets::id_with(field_ui.scope_id(), "field"); + field_ui.memory_mut(|m| m.request_focus(field_id)); + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + pick = Some(([hsb[0], f(p.x, square.left(), square.width()), 1.0 - f(p.y, square.top(), square.height())], phase)); + } + if let Some((x, y)) = field { + pick = Some(([hsb[0], x, 1.0 - y], field_phase)); + } + }); + let Some((hsb, phase)) = pick else { return }; + let behind = super::alt_held(ui); + let cmyk_doc = app.session.active().is_some_and(|d| d.doc.color_mode == ColorMode::Cmyk); + let c = stored(&cms, hsb, replaced(app, &tgt, behind).as_ref(), cmyk_doc); + apply(app, &tgt, c, phase, behind); + if !behind { + set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), cms.display_rgb(&c), hsb))); + } +} diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs new file mode 100644 index 000000000..798a842e4 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -0,0 +1,583 @@ +//! The Color Wheel panel drawn headless: Window › Color Wheel, picks on the hue ring and the +//! square, Shift and Alt, gradient stops, None, the remembered hue, the color model of a pick, the +//! arrow keys and what screen readers read. + +use egui::accesskit::{Action, ActionData, ActionRequest}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; +use vectorcraft_color::cms::{self, Cms, ColorSettings, delta_e2000}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_engine::Session; + +use super::color_wheel::{ID, display_hsb, remembered_hsb, show, stored}; +use crate::VectorcraftApp; + +/// An app with a selected rectangle filled with `fill`. +fn app(fill: serde_json::Value) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("paint.setFill", json!({"color": fill})).unwrap(); + app +} + +/// The panel in a 260-point-wide flyout, with the app's shortcuts after it as in a frame of the app. +fn harness(app: VectorcraftApp) -> Harness<'static, VectorcraftApp> { + let mut h = Harness::builder().with_size(vec2(260.0, 320.0)).build_ui_state( + |ui, app: &mut VectorcraftApp| { + show(app, ui); + crate::shortcuts::handle(app, ui.ctx()); + }, + app, + ); + h.run_steps(2); + h +} + +fn rect_of(h: &Harness<'static, VectorcraftApp>, label: &str) -> Rect { + h.get_by_label(label).rect() +} + +/// The point at hue `deg` on the ring drawn in `ring`, `out` points outside the middle of its band. +fn on_ring(ring: Rect, deg: f32, out: f32) -> Pos2 { + let r = ring.width() / 2.0 - 4.0 - 7.0 + out; + let a = deg.to_radians(); + ring.center() + vec2(a.cos(), -a.sin()) * r +} + +/// The point (x across, y up; 0..1 each) of the square. +fn in_square(sq: Rect, x: f32, y: f32) -> Pos2 { + Pos2::new(sq.left() + x * sq.width(), sq.bottom() - y * sq.height()) +} + +fn press(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(pos)); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); +} + +fn click(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2, modifiers: Modifiers) { + press(h, pos, true, modifiers); + press(h, pos, false, modifiers); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +/// A click whose press and release come in one frame (`Harness::event` gives each event a frame of +/// its own). +fn quick_click(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.run_steps(1); + let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([button(true), button(false)]); + h.run_steps(2); +} + +/// A drag through `points`, pressed at the first and released at the last. +fn drag(h: &mut Harness<'static, VectorcraftApp>, points: &[Pos2], modifiers: Modifiers) { + let (Some(first), Some(last)) = (points.first(), points.last()) else { return }; + press(h, *first, true, modifiers); + for p in points { + for _ in 0..2 { + h.event(Event::PointerMoved(*p)); + h.run_steps(1); + } + } + press(h, *last, false, modifiers); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +fn key(h: &mut Harness<'static, VectorcraftApp>, key: egui::Key, modifiers: Modifiers) { + h.key_press_modifiers(modifiers, key); + h.run_steps(1); +} + +fn fill(app: &VectorcraftApp) -> Color { + crate::panels::current_paints(app).0.color().expect("a solid fill") +} + +fn hsb(c: &Color) -> [f32; 3] { + display_hsb(&cms::active(), c) +} + +fn near(a: [f32; 3], b: [f32; 3], tol: [f32; 3]) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() <= tol[i]) +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +/// Window › Color Wheel opens the panel, and a click on the left of the ring turns a red fill cyan +/// at the same saturation and brightness. +#[test] +fn window_color_wheel_opens_the_panel_and_the_ring_picks_the_hue() { + let mut app = app(json!("#ff0000")); + app.run("window.panel", json!({"panel": ID})).unwrap(); + assert!(crate::panel_docking::showing(&app.ui, ID)); + assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "Color Wheel")); + let item = crate::menus::menu_entries(&app).into_iter().find(|e| e.path == ["Window"] && e.label == "Color Wheel"); + assert!(item.is_some(), "the Window menu lists it"); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + let undo = undo_len(h.state()); + click(&mut h, on_ring(ring, 180.0, 0.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); + assert_eq!(undo_len(h.state()), undo + 1, "one undo step"); +} + +#[test] +fn the_square_picks_saturation_across_and_brightness_up() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, sq.right_top() + vec2(-0.5, 0.5), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 1.0, 1.0], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); + click(&mut h, in_square(sq, 0.25, 0.5), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 0.25, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); +} + +/// A press between the square and the ring's inner edge picks the closest point of the square. +#[test] +fn a_press_inside_the_ring_beside_the_square_picks_its_closest_point() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let sq = rect_of(&h, "Saturation and brightness"); + // 1.4 points beyond the corner along the diagonal: the band's inner edge is 2 points out. + click(&mut h, sq.right_top() + vec2(1.0, -1.0), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 1.0, 1.0], [0.5, 1e-4, 1e-4]), "{:?}", hsb(&fill(h.state()))); +} + +/// The part a press starts on keeps the drag: from the ring into the square only the hue changes. +#[test] +fn a_drag_from_the_ring_into_the_square_changes_only_the_hue() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let (ring, sq) = (rect_of(&h, "Hue ring"), rect_of(&h, "Saturation and brightness")); + let before = hsb(&fill(h.state())); + drag(&mut h, &[on_ring(ring, 90.0, 0.0), sq.center() + vec2(-10.0, 0.0)], Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [180.0, before[1], before[2]], [0.5, 1e-3, 1e-3]), "{before:?} → {after:?}"); +} + +/// A press up to 4 points outside the band still picks a hue; one in the rect's corner does nothing. +#[test] +fn a_press_just_outside_the_ring_picks_a_hue_and_the_corners_do_nothing() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 180.0, 7.0 + 3.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); + click(&mut h, ring.left_top() + vec2(2.0, 2.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); +} + +/// Each click takes the part of the ring under it, also when its press and release come in one +/// frame, and a click on the band gives the ring the keyboard focus. +#[test] +fn a_click_in_one_frame_takes_the_part_under_it() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let (ring, sq) = (rect_of(&h, "Hue ring"), rect_of(&h, "Saturation and brightness")); + click(&mut h, ring.left_top() + vec2(2.0, 2.0), Modifiers::NONE); + quick_click(&mut h, on_ring(ring, 120.0, 0.0)); + assert!(near(hsb(&fill(h.state())), [120.0, 0.5, 0.6], [0.5, 1e-3, 1e-3]), "after a corner: {:?}", hsb(&fill(h.state()))); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [121.0, 0.5, 0.6], [0.5, 1e-3, 1e-3]), "focused: {:?}", hsb(&fill(h.state()))); + quick_click(&mut h, sq.right_top() + vec2(1.0, -1.0)); + assert!(near(hsb(&fill(h.state())), [121.0, 1.0, 1.0], [0.5, 1e-3, 1e-3]), "after the band: {:?}", hsb(&fill(h.state()))); +} + +#[test] +fn shift_drag_on_the_ring_steps_the_hue_by_45_degrees() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + drag(&mut h, &[on_ring(ring, 10.0, 0.0), on_ring(ring, 60.0, 0.0), on_ring(ring, 100.0, 0.0)], Modifiers::SHIFT); + assert!(near(hsb(&fill(h.state())), [90.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); +} + +#[test] +fn alt_click_paints_the_stroke_and_the_fill_stays_in_front() { + let mut app = app(json!("#ff0000")); + app.run("paint.setStroke", json!({"color": "#808080", "focus": false})).unwrap(); + assert!(app.session.fill_active); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 120.0, 0.0), Modifiers::ALT); + let (f, s) = crate::panels::current_paints(h.state()); + assert_eq!((f.color().unwrap().to_hex(), s.color().unwrap().to_hex()), ("#ff0000".into(), "#00ff00".into())); + assert!(h.state().session.fill_active, "the fill stays in front"); +} + +#[test] +fn a_pick_recolors_the_selected_gradient_stop() { + let mut app = app(json!("#ff0000")); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [10, 35], "end": [60, 35]}})).unwrap(); + app.select_tool("gradient"); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 120.0, 0.0), Modifiers::NONE); + let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") }; + let hex: Vec = g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(); + assert_eq!(hex, ["#ff0000", "#00ff00"]); +} + +/// With None there is no color to edit: no markers, and a click sets the fill. +#[test] +fn with_a_none_fill_the_wheel_has_no_marker_and_a_click_sets_the_fill() { + let mut app = app(json!("#ff0000")); + app.run("paint.setFill", json!({"none": true})).unwrap(); + let mut h = harness(app); + assert_eq!(h.get_by_label("Saturation and brightness").value(), None, "no marker"); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + let c = fill(h.state()); + assert!(near(hsb(&c), [hsb(&h.state().session.last_solid)[0], 0.5, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&c)); +} + +/// With None the ring and the square still take a screen reader's actions and the arrow keys. They +/// start from the last solid color the wheel shows, and the result sets the fill. +#[test] +fn with_a_none_fill_screen_readers_and_the_arrow_keys_set_the_fill() { + let mut app = app(json!("#ff0000")); + app.run("paint.setFill", json!({"none": true})).unwrap(); + let mut h = harness(app); + let last = hsb(&h.state().session.last_solid); + let (target_node, target_tree) = h.get_by_label("Hue ring").accesskit_node().locate(); + let data = Some(ActionData::NumericValue(200.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, last[1], last[2]], [0.5, 2e-3, 2e-3]), "{:?}", hsb(&fill(h.state()))); + // A click focuses the square; with the fill set to None again the arrows still step it. + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + h.state_mut().run("paint.setFill", json!({"none": true})).unwrap(); + h.run_steps(1); + assert_eq!(h.get_by_label("Saturation and brightness").value(), None, "no marker"); + let last = hsb(&h.state().session.last_solid); + key(&mut h, egui::Key::ArrowLeft, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [last[0], last[1] - 0.01, last[2] - 0.01], [0.5, 2e-3, 2e-3]), "{last:?} → {after:?}"); +} + +/// The hue stays while the square passes through a gray: a drag to the left edge (white, whose own +/// hue is 0°) and back ends at the color a click at its end gives, for an RGB and for a CMYK fill +/// (whose stored color differs from the picked RGB), and an RGB green is green again. +#[test] +fn a_gray_keeps_the_hue_it_was_dragged_from() { + for fill_json in [json!("#00ff00"), json!({"c": 0.8, "m": 0.1, "y": 0.9, "k": 0.0})] { + let mut h = harness(app(fill_json.clone())); + let before = hsb(&fill(h.state())); + let sq = rect_of(&h, "Saturation and brightness"); + // Inside the square's edges; the drag past its left edge picks saturation 0. + let at = |x: f32| in_square(sq, x.clamp(0.005, 0.995), before[2].clamp(0.005, 0.995)); + let gray = Pos2::new(sq.left() - 5.0, at(0.0).y); + drag(&mut h, &[at(before[1]), gray, at(before[1])], Modifiers::NONE); + let dragged = fill(h.state()); + let mut direct = harness(app(fill_json.clone())); + click(&mut direct, at(before[1]), Modifiers::NONE); + assert_eq!(dragged.to_hex(), fill(direct.state()).to_hex(), "{fill_json}: {before:?} → {:?}", hsb(&dragged)); + assert!((hsb(&dragged)[0] - before[0]).abs() < 5.0, "{fill_json}: {before:?} → {:?}", hsb(&dragged)); + } + let mut h = harness(app(json!("#00ff00"))); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() + 1.0), in_square(sq, 0.995, 0.995)], Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [120.0, 1.0, 1.0], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); +} + +/// The remembered hue belongs to the object it was picked on: another object of the same color +/// shows its own hue. +#[test] +fn the_remembered_hue_stays_with_its_object() { + let mut app = app(json!("#00ff00")); + let a = app.session.active().unwrap().selection.objects[0].0; + let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"color": "#ffffff", "ids": [b]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + // Green to white (saturation 0 at the top edge): the wheel keeps hue 120°. + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + assert_eq!(fill(h.state()), Color::WHITE); + h.state_mut().run("select.set", json!({"ids": [b]})).unwrap(); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let hue = hsb(&fill(h.state()))[0]; + assert!(!(1.0..359.0).contains(&hue), "the white object's own hue is 0°, not 120°: {hue}"); +} + +/// Equal colors on two appearance fills have separate remembered hues. A square pick on the +/// second white fill uses its own 0° hue after the first fill was changed from green to white. +#[test] +fn the_remembered_hue_stays_with_its_appearance_item() { + let mut app = app(json!("#00ff00")); + let id = app.session.active().unwrap().selection.objects[0]; + app.run("appearance.addFill", json!({})).unwrap(); // [Fill, Stroke, Fill] + app.run("paint.setFill", json!({"item": 2, "color": "#ffffff"})).unwrap(); + app.run("appearance.setActiveItem", json!({"index": 0})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + let item_fill = |app: &VectorcraftApp, index| { + app.session.doc().unwrap().doc.node(id).unwrap().appearance.fill_at(Some(index)).unwrap().paint.color().unwrap() + }; + assert_eq!(item_fill(h.state(), 0), Color::WHITE); + assert_eq!(item_fill(h.state(), 2), Color::WHITE); + h.state_mut().run("appearance.setActiveItem", json!({"index": 2})).unwrap(); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let hue = hsb(&item_fill(h.state(), 2))[0]; + assert!(!(1.0..359.0).contains(&hue), "the second fill's own hue is 0°, not 120°: {hue}"); + assert_eq!(item_fill(h.state(), 0), Color::WHITE); +} + +/// The Type tool can recolor a selected character range without changing the selected object. +/// The next range of the same color starts with its own hue. +#[test] +fn the_remembered_hue_stays_with_its_text_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 100, "text": "AB", "color": "#00ff00"})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "fill": "#ffffff"})).unwrap(); + app.select_tool("type"); + app.session.set_tool_option("textSelection", &json!({"id": id, "start": 0, "end": 1})); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + h.state_mut().session.set_tool_option("textSelection", &json!({"id": id, "start": 1, "end": 2})); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let node = h.state().session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!("still text") }; + assert_eq!(t.runs.first().unwrap().style.fill.color(), Some(Color::WHITE)); + let hue = hsb(&t.runs.last().unwrap().style.fill.color().unwrap())[0]; + assert!(!(1.0..359.0).contains(&hue), "the second character's own hue is 0°, not 120°: {hue}"); +} + +/// Replacing a gradient can keep the same selected stop and color while changing the paint it +/// belongs to. The new gradient starts with that stop's own hue. +#[test] +fn the_remembered_hue_stays_with_its_gradient() { + let mut app = app(json!("#00ff00")); + let first = json!([{"offset": 0, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]); + app.run("paint.setFill", json!({"gradient": {"stops": first}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 0})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + let second = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]); + h.state_mut().run("paint.setFill", json!({"gradient": {"stops": second}})).unwrap(); + assert_eq!(h.state().session.selected_stop(), Some(0)); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") }; + let hue = hsb(&g.gradient.stops[0].color)[0]; + assert!(!(1.0..359.0).contains(&hue), "the new gradient's white stop has hue 0°, not 120°: {hue}"); +} + +/// The remembered HSB holds only while the color displays as it did when picked: in another +/// working RGB space the wheel shows the color's own HSB there. +#[test] +fn the_remembered_hsb_follows_a_change_of_working_space() { + let srgb = Cms::default(); + let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..ColorSettings::default() }).unwrap(); + let c = Color::rgb(0.5, 0.3, 0.2); + let picked = [25.0, 0.6, 0.5]; + let shown = srgb.display_rgb(&c); + assert_eq!(remembered_hsb(&srgb, &c, shown, picked), picked); + let own = display_hsb(&p3, &c); + assert_ne!(own, picked); + assert_eq!(remembered_hsb(&p3, &c, shown, picked), own); +} + +/// A CMYK fill stays CMYK and keeps its look (ΔE 2000 under 2) when its marker is clicked; the +/// working profile separates the picked color again, so its inks can move. +#[test] +fn a_cmyk_fill_stays_cmyk_and_keeps_its_look() { + let [c, m, y, k] = Color::rgb(0.2, 0.5, 0.7).to_cmyk_managed(cms::active().settings().intent); + for inks in [[c, m, y, k], [0.2, 0.2, 0.2, 0.0]] { + let mut h = harness(app(json!({"c": inks[0], "m": inks[1], "y": inks[2], "k": inks[3]}))); + let before = fill(h.state()); + let [_, s, b] = hsb(&before); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, s, b), Modifiers::NONE); + let after = fill(h.state()); + assert!(matches!(after, Color::Cmyk { .. }), "{after:?}"); + let cms = cms::active(); + assert!(delta_e2000(cms.lab(&before), cms.lab(&after)) < cms::GAMUT_THRESHOLD, "{before:?} → {after:?}"); + } +} + +/// A Gray fill becomes a process color in the document's color mode. +#[test] +fn a_gray_fill_becomes_rgb_or_cmyk_with_the_document() { + for (mode, rgb) in [("rgb", true), ("cmyk", false)] { + let mut app = app(json!("#ff0000")); + app.run("file.documentColorMode", json!({"mode": mode})).unwrap(); + app.run("paint.setFill", json!({"color": {"gray": 0.5}})).unwrap(); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 0.0, 0.0), Modifiers::NONE); + let c = fill(h.state()); + assert_eq!(matches!(c, Color::Rgb { .. }), rgb, "{mode}: {c:?}"); + assert_eq!(matches!(c, Color::Cmyk { .. }), !rgb, "{mode}: {c:?}"); + } +} + +/// In a working RGB space wider than sRGB a pick goes into it through the color settings, so a hue +/// change keeps the displayed saturation and brightness. +#[test] +fn a_pick_in_display_p3_keeps_the_components_it_does_not_change() { + let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..ColorSettings::default() }).unwrap(); + let was = Color::rgb(0.5, 0.3, 0.2); + let [h, s, b] = display_hsb(&p3, &was); + let new = stored(&p3, [h + 40.0, s, b], Some(&was), false); + let [h2, s2, b2] = display_hsb(&p3, &new); + assert!(matches!(new, Color::Rgb { .. })); + assert!((h2 - (h + 40.0)).abs() < 1.0 && (s2 - s).abs() < 0.01 && (b2 - b).abs() < 0.01, "{:?} → {:?}", [h, s, b], [h2, s2, b2]); +} + +/// A press gives the ring or the square the keyboard focus. The arrows then step the color, each +/// press one undo step, the app's tool keys wait, and after Escape they work again. +#[test] +fn the_arrow_keys_step_the_focused_ring_or_square() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 0.0, 0.0), Modifiers::NONE); + let undo = undo_len(h.state()); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [1.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + key(&mut h, egui::Key::ArrowUp, Modifiers::SHIFT); + assert!(near(hsb(&fill(h.state())), [11.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + assert_eq!(undo_len(h.state()), undo + 2, "a step each"); + key(&mut h, egui::Key::ArrowLeft, Modifiers::SHIFT); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [359.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "wraps: {:?}", hsb(&fill(h.state()))); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + let before = hsb(&fill(h.state())); + key(&mut h, egui::Key::ArrowUp, Modifiers::NONE); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [before[0], before[1] + 0.01, before[2] + 0.01], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}"); + // The tool keys wait while a control has the focus. + let tool = h.state().session.tool_id().to_string(); + let p = |h: &mut Harness<'static, VectorcraftApp>| { + h.event(Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Text("p".into())); + h.run_steps(1); + }; + p(&mut h); + assert_eq!(h.state().session.tool_id(), tool); + key(&mut h, egui::Key::Escape, Modifiers::NONE); + p(&mut h); + assert_eq!(h.state().session.tool_id(), "pen"); +} + +/// Screen readers find the ring as a slider from 0 to 360 that their actions move, and the square +/// with both of its components, whose actions move its saturation as → and ← do. +#[test] +fn screen_readers_read_and_move_the_ring_and_the_square() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let ring = h.get_by_label("Hue ring"); + let node = ring.accesskit_node(); + assert_eq!((node.min_numeric_value(), node.max_numeric_value(), node.numeric_value_step()), (Some(0.0), Some(360.0), Some(1.0))); + assert!([Action::Increment, Action::Decrement, Action::SetValue].iter().all(|a| node.data().supports_action(*a))); + assert_eq!(node.numeric_value(), Some(0.0)); + let (target_node, target_tree) = node.locate(); + assert_eq!(h.get_by_label("Saturation and brightness").value().as_deref(), Some("Saturation 50%, Brightness 60%")); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [1.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let data = Some(ActionData::NumericValue(200.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let square = h.get_by_label("Saturation and brightness"); + let node = square.accesskit_node(); + assert_eq!((node.min_numeric_value(), node.max_numeric_value(), node.numeric_value_step()), (Some(0.0), Some(100.0), Some(1.0))); + assert!([Action::Increment, Action::Decrement, Action::SetValue].iter().all(|a| node.data().supports_action(*a))); + assert!(node.numeric_value().is_some_and(|v| (v - 50.0).abs() < 1e-3), "{:?}", node.numeric_value()); + let (target_node, target_tree) = node.locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.51, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let data = Some(ActionData::NumericValue(20.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.2, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Decrement, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.19, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); +} + +#[test] +fn a_huge_set_value_on_the_ring_wraps_like_any_other_hue() { + let mut h = harness(app(json!([0.3, 0.6, 0.3]))); + let (target_node, target_tree) = h.get_by_label("Hue ring").accesskit_node().locate(); + let data = Some(ActionData::NumericValue(1e300)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + let color = fill(h.state()); + let Color::Rgb { r, g, b } = color else { panic!("expected RGB fill: {color:?}") }; + assert!([r, g, b].iter().all(|v| v.is_finite()), "{color:?}"); + assert!(hsb(&color).iter().all(|v| v.is_finite()), "{:?}", hsb(&color)); + assert!(near(hsb(&color), [0.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&color)); +} + +/// A step past the edge of the square leaves the fill alone: a CMYK cyan shows at 100% saturation, +/// and a step up from there neither separates it again nor adds an undo step. +#[test] +fn a_step_past_the_edge_of_the_square_changes_nothing() { + let mut h = harness(app(json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}))); + let before = fill(h.state()); + assert_eq!(hsb(&before)[1], 1.0, "{:?}", hsb(&before)); + let undo = undo_len(h.state()); + let (target_node, target_tree) = h.get_by_label("Saturation and brightness").accesskit_node().locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert_eq!(fill(h.state()), before); + assert_eq!(undo_len(h.state()), undo, "no undo step"); +} + +/// The Color Picker's color field is the same widget: a press focuses it, the arrows step the +/// components it shows, and screen readers read its name and both components and step the first. +#[test] +fn the_color_pickers_field_takes_the_arrow_keys() { + let mut app = app(json!([0.6, 0.3, 0.3])); + app.run("ui.colorPicker", json!({})).unwrap(); + let mut fonts = false; + let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + // The fonts take effect from the next frame. + if !fonts { + crate::theme::install_fonts(ui.ctx()); + fonts = true; + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let field = rect_of(&h, "Color field"); + assert_eq!(h.get_by_label("Color field").value().as_deref(), Some("Saturation 50%, Brightness 60%")); + click(&mut h, field.center(), Modifiers::NONE); + let picked = |h: &Harness<'static, VectorcraftApp>| -> [f32; 3] { + serde_json::from_value(h.state().ui.dialog.as_ref().unwrap().fields["__hsb"].clone()).unwrap() + }; + let before = picked(&h); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::SHIFT); + let after = picked(&h); + assert!(near(after, [before[0], before[1] + 0.01, before[2] - 0.1], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}"); + let (target_node, target_tree) = h.get_by_label("Color field").accesskit_node().locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + let stepped = picked(&h); + assert!(near(stepped, [after[0], after[1] + 0.01, after[2]], [1e-3, 1e-4, 1e-4]), "{after:?} → {stepped:?}"); + assert!(h.state().ui.dialog.is_some(), "the dialog stays open"); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1b93832ae..d7f3cc579 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -201,6 +201,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("attributes", "Attributes", "settings"), (crate::panels::svg_interactivity::ID, "SVG Interactivity", "spline-pointer"), ("colorThemes", "Color Themes", "sun"), + (crate::panels::color_wheel::ID, "Color Wheel", "circle"), (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), (crate::panels::css_properties::ID, "CSS Properties", "globe"), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 20cfd68b3..4f3243c75 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -33,12 +33,12 @@ pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, s #[cfg(test)] use chips::gradient_chip_colors; pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; -pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase}; pub use color::{ - Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider, - color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, segment_color, - segmented_wheel, stepped_area, + FieldAxis, Live, RingResponse, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, + color_slider, color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, hsb32, hue_ring, + region_texture, ring_square, segment_color, segmented_wheel, stepped_area, }; +pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase}; pub use drag::{ PanelDrag, RowClick, SwatchRows, apply_row_click, art_drop, doc_preview, drag_ghost, drag_source, drop_target_outline, ghost_row, row_click, }; diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 72f74f7b9..f3d34d23e 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -1,5 +1,5 @@ use super::labels::{label_field, name_field, take_previous_label}; -use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple}; +use super::numeric::{STEP_DECIMALS, STEPS, field_text, round_to, step_to_multiple}; use super::text::{after_shift_enter, select_all, select_all_on_focus, shift_enter_keeps_focus}; use super::{Color, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, pos2, vec2}; @@ -320,31 +320,130 @@ fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn( } } -/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y -/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. +/// One axis of a [`color_field`] as keys and screen readers see it: the component's name, how many +/// of its units the axis spans (an arrow press moves one unit) and the units' suffix. +#[derive(Clone, Copy, Debug)] +pub struct FieldAxis<'a> { + pub name: &'a str, + pub units: f32, + pub suffix: &'a str, +} + +/// A press on `resp`, or a click whose press and release came in one frame, gives it the keyboard +/// focus, so the arrow keys reach it. Escape or a click elsewhere takes the focus away again. +fn focus_on_press(resp: &Response) { + if resp.is_pointer_button_down_on() || resp.clicked() { + resp.request_focus(); + } +} + +/// The arrow keys pressed for control `id` this frame while it has the keyboard focus, in units +/// ([`STEPS`]: ten with Shift, a tenth with Ctrl/Cmd, else one): (→ minus ←, ↑ minus ↓). The control +/// takes the arrows of each axis it uses (`horizontal`, `vertical`), which then do not move the focus +/// to the next widget. +fn arrow_steps(ui: &Ui, id: egui::Id, horizontal: bool, vertical: bool) -> (f32, f32) { + if !ui.memory(|m| m.has_focus(id)) { + return (0.0, 0.0); + } + use egui::Key; + let filter = egui::EventFilter { horizontal_arrows: horizontal, vertical_arrows: vertical, ..Default::default() }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + let (mut x, mut y) = (0.0, 0.0); + ui.input_mut(|i| { + for (mods, size) in STEPS { + let mut count = |key| i.count_and_consume_key(mods, key) as f64 * size; + if horizontal { + x += count(Key::ArrowRight) - count(Key::ArrowLeft); + } + if vertical { + y += count(Key::ArrowUp) - count(Key::ArrowDown); + } + } + }); + (x as f32, y as f32) +} + +/// The focus outline of a focused color control. +fn focus_outline(ui: &Ui, resp: &Response, rect: Rect) { + if ui.memory(|m| m.has_focus(resp.id)) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(rect.expand(2.0), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Outside); + } +} + +/// A 2D color field (the Color Picker's, the Color Wheel panel's square): `color_at(x, y)` gives +/// the color at a point (0..1 each, y down) and, when `marked`, a ring marks `pos`. A press gives +/// it the keyboard focus. While it has the focus, → and ← move `pos` across by one unit of +/// `axes[0]` and ↑ and ↓ move it up and down by one unit of `axes[1]`, marked or not. Screen +/// readers read `name` and, when `marked`, both components. They read it as a slider over +/// `axes[0]`'s units (0 when not `marked`), which their increment and decrement actions move as → +/// and ← do and their set-value action sets, as for a [`hue_ring`]. Returns the clicked or dragged +/// point, or the point the keys or actions moved to, and the phase. When `marked`, a step past an +/// edge returns no point. pub fn color_field( ui: &mut Ui, id: impl std::hash::Hash, + (name, axes): (&str, [FieldAxis<'_>; 2]), size: Vec2, pos: (f32, f32), + marked: bool, color_at: &dyn Fn(f32, f32) -> Color32, ) -> (Option<(f32, f32)>, Live) { + use egui::accesskit::{Action, ActionData}; let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + focus_on_press(&resp); let painter = ui.painter_at(rect.expand(6.0)); painter.add(color_mesh(rect, (32, 32), color_at)); painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); - painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); - painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); - match pointer_phase(&resp) { - Some((p, phase)) => { - let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); - (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) - } - None => (None, Live::Idle), + focus_outline(ui, &resp, rect); + let (x, y) = pos; + if marked { + let c = pos2(rect.left() + x.clamp(0.0, 1.0) * rect.width(), rect.top() + y.clamp(0.0, 1.0) * rect.height()); + painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); + painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); } + let value = |v: f32, a: &FieldAxis| format!("{}{}", (v.clamp(0.0, 1.0) * a.units).round(), a.suffix); + let text = marked.then(|| { + crate::i18n::fmt( + tl!("{name1} {value1}, {name2} {value2}"), + &[("name1", axes[0].name), ("value1", &value(x, &axes[0])), ("name2", axes[1].name), ("value2", &value(1.0 - y, &axes[1]))], + ) + }); + let shown = if marked { f64::from(x.clamp(0.0, 1.0) * axes[0].units) } else { 0.0 }; + resp.widget_info(|| egui::WidgetInfo { current_text_value: text.clone(), ..egui::WidgetInfo::slider(ui.is_enabled(), shown, name) }); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(0.0); + node.set_max_numeric_value(f64::from(axes[0].units)); + node.set_numeric_value_step(1.0); + for action in [Action::SetValue, Action::Increment, Action::Decrement] { + node.add_action(action); + } + }); + let (dx, dy) = arrow_steps(ui, resp.id, true, true); + let (steps, set) = ui.input(|i| { + let steps = + i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32; + let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data { + Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v as f32), + _ => None, + }); + (steps, set) + }); + if let Some((p, phase)) = pointer_phase(&resp) { + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + return (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase); + } + let across = set.map_or(x + (dx + steps) / axes[0].units, |v| v / axes[0].units); + let moved = (across.clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); + // A step past an edge leaves the point where it is. Without a marker there is no color yet, so + // a key or an action applies even when the point stays. + let asked = set.is_some() || dx + steps != 0.0 || dy != 0.0; + if moved != (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)) || (asked && !marked) { + return (Some(moved), Live::Released); + } + (None, Live::Idle) } /// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1 @@ -628,6 +727,152 @@ fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape { egui::Shape::mesh(mesh) } +/// The width of a [`hue_ring`]'s band. +const RING_WIDTH: f32 = 14.0; +/// How far inside its rect a [`hue_ring`]'s band is drawn: a press up to this far outside the band +/// still picks a hue. +const RING_MARGIN: f32 = 4.0; + +/// The display sRGB of HSB `[h°, s, v]` as an egui color, with no color conversion. +pub fn hsb32(hsb: [f32; 3]) -> Color32 { + let [r, g, b] = vectorcraft_color::hsb_to_rgb(hsb).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + Color32::from_rgb(r, g, b) +} + +/// The square a [`hue_ring`] drawn in `rect` holds: the largest one inside the band's inner edge, +/// 2 points clear of it. +pub fn ring_square(rect: Rect) -> Rect { + let inner = rect.width().min(rect.height()) / 2.0 - RING_MARGIN - RING_WIDTH - 2.0; + Rect::from_center_size(rect.center(), Vec2::splat(inner.max(0.0) * std::f32::consts::SQRT_2)) +} + +/// The part of a [`hue_ring`] a press started on. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RingPart { + Band, + Inner, + Corner, +} + +/// What a [`hue_ring`] did this frame. +pub struct RingResponse { + /// The rect the ring is drawn in. + pub rect: Rect, + /// A new hue (degrees, 0..360) from a press or drag on the band, an arrow key or a screen + /// reader, and its phase. + pub hue: Option<(f32, Live)>, + /// The pointer of a press or drag that started inside the band's inner edge, and its phase. + pub inner: Option<(Pos2, Live)>, +} + +/// A hue ring `size` across: a band of hues drawn [`RING_MARGIN`] inside its rect, the hue +/// increasing counterclockwise from 3 o'clock as on [`harmony_wheel`], with a marker at `hue` when +/// `marked`. A press on the band, or up to [`RING_MARGIN`] outside it, picks the hue under the +/// pointer (with Shift, in 45° steps) and gives the ring the keyboard focus. A press inside the +/// band's inner edge comes back as `inner`, and a press in the rect's corners does nothing. The part +/// a press starts on keeps the whole drag. While the ring has the focus, → and ↑ raise `hue` by +/// 1° and ← and ↓ lower it ([`STEPS`]), marked or not, and 360° wraps to 0°. Screen readers read it +/// as a slider named `name` from 0 to 360 (0 when not `marked`), which their increment, decrement +/// and set-value actions move. +pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hue: f32, marked: bool) -> RingResponse { + use egui::accesskit::{Action, ActionData}; + const SEG: u32 = 72; + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let c = rect.center(); + let outer = size / 2.0 - RING_MARGIN; + let inner = outer - RING_WIDTH; + let mut mesh = egui::Mesh::default(); + for k in 0..=SEG { + let h = k as f32 * 360.0 / SEG as f32; + let col = hsb32([h, 1.0, 1.0]); + mesh.colored_vertex(wheel_pos(c, inner, h, 1.0), col); + mesh.colored_vertex(wheel_pos(c, outer, h, 1.0), col); + } + for k in 0..SEG { + let a = 2 * k; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a + 1, a + 3, a + 2); + } + let painter = ui.painter_at(rect); + painter.add(egui::Shape::mesh(mesh)); + painter.circle_stroke(c, outer, Stroke::new(1.0, t.border)); + painter.circle_stroke(c, inner, Stroke::new(1.0, t.border)); + if ui.memory(|m| m.has_focus(resp.id)) { + painter.circle_stroke(c, outer + 2.0, Stroke::new(1.0, t.accent)); + } + if marked { + let m = wheel_pos(c, (inner + outer) / 2.0, hue, 1.0); + painter.circle_stroke(m, RING_WIDTH / 2.0 - 1.0, Stroke::new(1.5, Color32::WHITE)); + painter.circle_stroke(m, RING_WIDTH / 2.0, Stroke::new(1.0, Color32::BLACK)); + } + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), if marked { f64::from(hue) } else { 0.0 }, name)); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(0.0); + node.set_max_numeric_value(360.0); + node.set_numeric_value_step(1.0); + for action in [Action::SetValue, Action::Increment, Action::Decrement] { + node.add_action(action); + } + }); + let part_at = |p: Pos2| match p.distance(c) { + d if d < inner => RingPart::Inner, + d if d <= outer + RING_MARGIN => RingPart::Band, + _ => RingPart::Corner, + }; + let part_id = resp.id.with("part"); + if resp.is_pointer_button_down_on() + && ui.input(|i| i.pointer.primary_pressed()) + && let Some(p) = resp.interact_pointer_pos() + { + ui.data_mut(|d| d.insert_temp(part_id, part_at(p))); + } + let pointer = pointer_phase(&resp); + // A click takes the part under it; a drag keeps the part its press started on. + let part = match pointer { + Some((p, _)) if resp.clicked() => Some(part_at(p)), + Some((p, _)) => Some(ui.data(|d| d.get_temp::(part_id)).unwrap_or_else(|| part_at(p))), + None => ui.data(|d| d.get_temp::(part_id)), + }; + if resp.clicked() || resp.drag_stopped() { + ui.data_mut(|d| d.remove::(part_id)); + } + if part == Some(RingPart::Band) { + focus_on_press(&resp); + } + let (dx, dy) = arrow_steps(ui, resp.id, true, true); + let (steps, set) = ui.input(|i| { + let steps = + i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32; + let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data { + Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v.rem_euclid(360.0) as f32), + _ => None, + }); + (steps + dx + dy, set) + }); + let mut out = RingResponse { rect, hue: None, inner: None }; + if let Some((p, phase)) = pointer { + match part.unwrap_or_else(|| part_at(p)) { + RingPart::Band => { + let v = p - c; + let h = (-v.y).atan2(v.x).to_degrees(); + let h = if ui.input(|i| i.modifiers.shift) { (h / 45.0).round() * 45.0 } else { h }; + out.hue = Some((h.rem_euclid(360.0), phase)); + } + RingPart::Inner => out.inner = Some((p, phase)), + RingPart::Corner => {} + } + } else { + let new = set.unwrap_or(hue + steps); + // Without a marker there is no color yet, so a set value applies even when it equals `hue`. + if new != hue || (set.is_some() && !marked) { + out.hue = Some((new.rem_euclid(360.0), Live::Released)); + } + } + out +} + /// Hue sectors and saturation rings of the [`segmented_wheel`]: patches 15° of hue by 20% /// saturation wide, the sectors centered on red at 0°. const WHEEL_SECTORS: u32 = 24; diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index ac6b1ba8f..04ac7160f 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -192,7 +192,7 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 { /// One step of a numeric field with each modifier, most specific first (a plain pattern would also /// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to /// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten. -const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; +pub(super) const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; /// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next /// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d85f3c19d..b3f4f8247 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -247,6 +247,11 @@ Color Themes panel (`window.panel {panel: "colorThemes"}`): its Create tab makes My Themes tab lists the saved themes; every action runs a command (`colorTheme.save`, `colorTheme.delete`, `colorTheme.addToSwatches`, `swatch.newGroup` for the theme being made), and `colorTheme.list` reads the library. +Color Wheel panel (`window.panel {panel: "colorWheel"}`): a hue ring around a saturation and brightness square. A +pick runs the commands the Color panel's sliders run: `paint.setFill` or `paint.setStroke` with `keepModel`, +`paint.editGradient` for a gradient's selected stop, and `paint.freeform.setPoint` for a freeform gradient's selected +point (the first stop or point when none is selected). + Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/ `global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` / `tint` (global mode) from −100 to 100, `convert`, `fill`, `stroke`, `preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d37a5dd13..08d34a29d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1186,6 +1186,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels. - **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel. - **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given. + - **Color Wheel panel (part of #978):** Window › Color Wheel shows the Fill and Stroke proxy beside a hue ring around a square with saturation across and brightness up. It edits what the Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform point) through the same paint commands, and a drag is one undo step. Alt paints the other proxy, and Shift steps the ring by 45°. A pick goes from display sRGB into the working RGB space; an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch becomes a process color in the document's color mode. A click gives the ring or the square the keyboard focus, and the arrow keys then step the color; both report their names and values to screen readers, and the Color Picker's color field takes the same keys. Scores and estimates are unchanged. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB grays separate into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`.