Part of #978: Window › Color Wheel picks colors on a hue ring and a saturation and brightness square

Window › Color Wheel opens a new panel: the Fill and Stroke proxy
beside a hue ring around a square with saturation across and
brightness up at the ring's hue. 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°. The Color panel does not change.

A pick is display sRGB and goes into the working RGB space through the
color settings. 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 wheel keeps the hue of the last pick while
the objects, the proxy and the color are unchanged, so a drag through
a gray and back keeps the hue.

A press gives the ring or the square the keyboard focus. The arrow
keys then step the hue by 1° or the saturation and brightness by 1%.
With Shift a press steps ten times as far, and with Cmd/Ctrl it steps
a tenth as far. Each press makes one undo step, and Escape or a click
elsewhere gives the keys back to the tools. Screen readers
read the ring as a slider from 0 to 360 with increment, decrement and
set-value actions, and the square as its name and both values. The
Color Picker's color field is the same widget, so it takes the same
keys and reports its name and values.

Color::to_hsb and Color::from_hsb now call the new rgb_to_hsb and
hsb_to_rgb, which the panel uses on display sRGB numbers.
This commit is contained in:
dotCooCoo
2026-11-08 18:43:48 -08:00
parent bf0ceb661f
commit 31769bd8ad
28 changed files with 966 additions and 53 deletions
+42 -30
View File
@@ -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 {
+20 -1
View File
@@ -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), Some((x, y)), &at)
{
pick = Some(field_pick(channel, z, x, y));
}
ui.add_space(SLIDER_GAP);
+5
View File
@@ -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í
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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 分割・統合プレビュー
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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 병합 미리 보기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -511,6 +511,7 @@ antiAlias Sharp Nítido
Color Swatches Amostras de Cor
Color Table Tabela de Cores
Color Themes Temas de Cores
Color Wheel Roda de Cores
Color Type: Tipo de Cor:
Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopia
Color blindness – Protanopia-type Daltonismo – tipo protanopia
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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 Тонировать
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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 Тонувати
Can't render this file because it contains an unexpected character in line 182 and column 5.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+5
View File
@@ -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 上色
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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 上色
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -644,6 +644,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
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),
+4
View File
@@ -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;
+10 -5
View File
@@ -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<Color>, Option<Tint>),
Stop { paint: Paint, index: usize, color: Color, tint: Option<Tint> },
Point { index: usize, color: Color },
}
impl Target {
fn color(&self) -> Option<Color> {
pub(crate) fn color(&self) -> Option<Color> {
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();
+151
View File
@@ -0,0 +1,151 @@
//! 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 and the color 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 and its [`Owner`].
const MEMORY: &str = "color-wheel-hsb";
/// What the HSB picked last belongs to: the document, the selected objects, the proxy, the
/// gradient stop or freeform point, 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<u64>,
selection: Vec<NodeId>,
fill: bool,
/// The selected gradient stop (`Some((false, i))`) or freeform point (`Some((true, i))`).
slot: Option<(bool, usize)>,
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(),
fill: app.session.fill_active,
slot,
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 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<Color> {
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 applies the picked color.
let hsb = match (color, pstate::<Option<(Owner, [f32; 3])>>(ui.ctx(), MEMORY)) {
(Some(c), Some((owner, hsb))) if owner == Owner::of(app, &tgt, c) => 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, marked.then_some(hsb[0]));
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 pos = marked.then_some((hsb[1], 1.0 - hsb[2]));
let name = tl!("Saturation and brightness");
let (field, field_phase) = widgets::color_field(&mut field_ui, "field", (name, axes), square.size(), pos, &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);
if !behind {
set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), hsb)));
}
apply(app, &tgt, c, phase, behind);
}
@@ -0,0 +1,423 @@
//! 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, 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<String> = 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));
}
/// 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}");
}
/// 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.
#[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())));
}
/// 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.
#[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:?}");
assert!(h.state().ui.dialog.is_some(), "the dialog stays open");
}
+1
View File
@@ -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"),
+4 -4
View File
@@ -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,
};
+232 -12
View File
@@ -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,30 +320,106 @@ 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 a ring marks `pos` (no ring for `None`). A press
/// gives it the keyboard focus. While it has the focus, → and ← move the point across by one unit
/// of `axes[0]` and ↑ and ↓ move it up and down by one unit of `axes[1]`. Screen readers read
/// `name` and both components. Returns the clicked or dragged point, or the point the keys moved
/// to, and the phase.
pub fn color_field(
ui: &mut Ui,
id: impl std::hash::Hash,
(name, axes): (&str, [FieldAxis<'_>; 2]),
size: Vec2,
pos: (f32, f32),
pos: Option<(f32, f32)>,
color_at: &dyn Fn(f32, f32) -> Color32,
) -> (Option<(f32, f32)>, Live) {
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)
focus_outline(ui, &resp, rect);
if let Some((x, y)) = pos {
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 = pos.map(|(x, y)| {
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]))],
)
});
resp.widget_info(|| egui::WidgetInfo {
current_text_value: text.clone(),
..egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name)
});
let (dx, dy) = arrow_steps(ui, resp.id, true, true);
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);
}
match pos {
Some((x, y)) if dx != 0.0 || dy != 0.0 => {
let moved = ((x + dx / axes[0].units).clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0));
(Some(moved), Live::Released)
}
None => (None, Live::Idle),
_ => (None, Live::Idle),
}
}
@@ -628,6 +704,150 @@ 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` (none
/// for `None`). 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 the hue by
/// 1° and ← and ↓ lower it ([`STEPS`]), and 360° wraps to 0°. Screen readers read it as a slider
/// named `name` from 0 to 360, 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: Option<f32>) -> 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 let Some(h) = hue {
let m = wheel_pos(c, (inner + outer) / 2.0, h, 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(), f64::from(hue.unwrap_or(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::<RingPart>(part_id)).unwrap_or_else(|| part_at(p))),
None => ui.data(|d| d.get_temp::<RingPart>(part_id)),
};
if resp.clicked() || resp.drag_stopped() {
ui.data_mut(|d| d.remove::<RingPart>(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 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 if let Some(h) = hue {
let new = set.unwrap_or(h + steps);
if new != h {
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;
+1 -1
View File
@@ -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
+5
View File
@@ -246,6 +246,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
+1
View File
@@ -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.
- **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`.