Select Color: CMYK sliders, field below the sliders, Document first

- Editor modes RGB, HSB and CMYK; the saturation/brightness field and hue
  strip now sit below the sliders in every mode instead of being a mode.
- The panel opens on the Document tab.
- Built-in colour books: RAL CLASSIC and SIKKENS 4041 (Open Colour
  Systems Collection 2.0, CC BY-ND 4.0); CSS COLORS removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
Claude committed 2026-10-14 11:57:53 +00:00
1 parent 00b925b9fa
commit ecdead20c7
7 files changed
+2205 -323

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@ impl ColorBook {
pub fn builtin() -> impl Iterator<Item = ColorBook> {
[
include_str!("../../../assets/color_books/ral-classic.acb"),
include_str!("../../../assets/color_books/css-colors.acb"),
include_str!("../../../assets/color_books/sikkens-4041.acb"),
]
.into_iter()
.filter_map(|text| parse(text).ok())
+67 -94
View File
@@ -1,6 +1,6 @@
//! The colour editor: HSB or RGB sliders, or a saturation/brightness field
//! with a hue strip, plus a hex box. Shared by the CAD Select Color panel and
//! the interface colour picker.
//! The colour editor: RGB, HSB or CMYK sliders above a saturation/brightness
//! field with a hue strip, plus a hex box. Shared by the CAD Select Color
//! panel and the interface colour picker.
use super::math::{self, Hsb};
use super::widgets::ColorArea;
@@ -15,19 +15,17 @@ use iced::{Background, Border, Color, Element, Gradient, Length, Radians, Theme}
const ROW_HEIGHT: f32 = 26.0;
const TRACK: f32 = 18.0;
const STRIP_WIDTH: f32 = 22.0;
/// Gap between slider rows. Together with `FIELD_HEIGHT` it puts the hex row
/// at the same height in every mode: 3 × 26 + 2 × 14 + 12 = 108 + 10.
const ROW_GAP: f32 = 14.0;
const FIELD_HEIGHT: f32 = 108.0;
const MODE_WIDTH: f32 = 40.0;
const ROW_GAP: f32 = 8.0;
const FIELD_HEIGHT: f32 = 96.0;
const MODE_WIDTH: f32 = 48.0;
const MODE_HEIGHT: f32 = 24.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EditorMode {
Rgb,
#[default]
Hsb,
Rgb,
Field,
Cmyk,
}
#[derive(Debug, Clone)]
@@ -43,6 +41,8 @@ pub enum EditorMsg {
Red(u8),
Green(u8),
Blue(u8),
/// One CMYK channel (0 cyan … 3 black), in percent.
Cmyk(usize, f32),
HexInput(String),
HexSubmit,
}
@@ -51,6 +51,8 @@ pub struct ColorEditor {
mode: EditorMode,
/// Kept beside `rgb` so hue and saturation survive grays and black.
hsb: Hsb,
/// Kept too, so black keeps its inks and rounding doesn't move them.
cmyk: [f32; 4],
rgb: (u8, u8, u8),
hex_draft: Option<String>,
}
@@ -64,6 +66,7 @@ impl ColorEditor {
s: 0.0,
b: 1.0,
},
cmyk: [0.0; 4],
rgb,
hex_draft: None,
};
@@ -85,6 +88,7 @@ impl ColorEditor {
s: if next.b == 0.0 { self.hsb.s } else { next.s },
b: next.b,
};
self.cmyk = math::rgb_to_cmyk(r, g, b);
self.rgb = (r, g, b);
self.hex_draft = None;
}
@@ -96,9 +100,17 @@ impl ColorEditor {
b: hsb.b.clamp(0.0, 1.0),
};
self.rgb = self.hsb.to_rgb();
self.cmyk = math::rgb_to_cmyk(self.rgb.0, self.rgb.1, self.rgb.2);
self.hex_draft = None;
}
fn set_cmyk(&mut self, channel: usize, value: f32) {
let mut cmyk = self.cmyk;
cmyk[channel.min(3)] = value.clamp(0.0, 100.0);
self.set_rgb(math::cmyk_to_rgb(cmyk));
self.cmyk = cmyk;
}
/// Apply a message; returns whether the colour changed.
pub fn update(&mut self, msg: EditorMsg) -> bool {
let before = self.rgb;
@@ -118,6 +130,7 @@ impl ColorEditor {
EditorMsg::Red(v) => self.set_rgb((v, g, b)),
EditorMsg::Green(v) => self.set_rgb((r, v, b)),
EditorMsg::Blue(v) => self.set_rgb((r, g, v)),
EditorMsg::Cmyk(channel, v) => self.set_cmyk(channel, v),
EditorMsg::HexInput(value) => {
// Six digits are unambiguous, so apply them while typing; the
// short #RGB form waits for Enter.
@@ -138,28 +151,26 @@ impl ColorEditor {
/// `mode_end` sits at the far right of the mode buttons (the pipette);
/// `hex_end` at the far right of the hex row (the nearest index colour).
/// The field view shows the hex value beside H, S and B and leaves out
/// `hex_end`; the slider views end on a hex row.
pub fn view<'a>(
&'a self,
mode_end: Option<Element<'a, Message>>,
hex_end: Option<Element<'a, Message>>,
) -> Element<'a, Message> {
let view = column![self.mode_row(mode_end)].spacing(12);
match self.mode {
EditorMode::Hsb | EditorMode::Rgb => view
.push(self.slider_rows())
.push(self.hex_row(hex_end))
.into(),
EditorMode::Field => view.push(self.field_view()).into(),
}
column![
self.mode_row(mode_end),
self.slider_rows(),
self.field(),
self.hex_row(hex_end),
]
.spacing(12)
.into()
}
/// HSB, RGB and the gradient-field button, with `end` at the far right.
/// RGB, HSB and CMYK, with `end` at the far right.
fn mode_row<'a>(&self, end: Option<Element<'a, Message>>) -> Element<'a, Message> {
let mode_button = |content: Element<'a, Message>, mode: EditorMode| {
let mode_button = |label: &'static str, mode: EditorMode| {
let active = self.mode == mode;
button(container(content).center(Length::Fill))
button(container(text(label).size(13)).center(Length::Fill))
.on_press(msg(EditorMsg::Mode(mode)))
.width(MODE_WIDTH)
.height(MODE_HEIGHT)
@@ -168,35 +179,10 @@ impl ColorEditor {
super::picker::toggle_style(theme, active, status)
})
};
// The field button is only its gradient, without a frame; it takes
// colour when its mode is active and stays gray otherwise.
let field_active = self.mode == EditorMode::Field;
let field_button = button(Space::new().width(MODE_WIDTH).height(MODE_HEIGHT))
.on_press(msg(EditorMsg::Mode(EditorMode::Field)))
.padding(0)
.style(move |_theme: &Theme, _status| {
let middle = if field_active {
Color::from_rgb8(0x3A, 0xA0, 0xFF)
} else {
Color::from_rgb8(0x80, 0x80, 0x80)
};
let icon = gradient_box(
3.0 * std::f32::consts::FRAC_PI_4,
&[(0.0, Color::WHITE), (0.5, middle), (1.0, Color::BLACK)],
);
button::Style {
background: icon.background,
border: Border {
radius: 5.0.into(),
..Border::default()
},
..Default::default()
}
});
let mut modes = row![
mode_button(text("HSB").size(13).into(), EditorMode::Hsb),
mode_button(text("RGB").size(13).into(), EditorMode::Rgb),
field_button,
mode_button("RGB", EditorMode::Rgb),
mode_button("HSB", EditorMode::Hsb),
mode_button("CMYK", EditorMode::Cmyk),
Space::new().width(Length::Fill),
]
.spacing(6);
@@ -206,12 +192,29 @@ impl ColorEditor {
modes.into()
}
/// Three gradient sliders: hue, saturation and brightness, or red, green
/// and blue.
/// Gradient sliders: red, green and blue; hue, saturation and
/// brightness; or cyan, magenta, yellow and black.
fn slider_rows<'a>(&self) -> Element<'a, Message> {
let Hsb { h, s, b } = self.hsb;
let (r, g, bl) = self.rgb;
let rows = if self.mode == EditorMode::Rgb {
let rows = if self.mode == EditorMode::Cmyk {
let ink = |i: usize, letter| {
let at = |v| {
let mut cmyk = self.cmyk;
cmyk[i] = v;
rgb_color(math::cmyk_to_rgb(cmyk))
};
channel(
letter,
self.cmyk[i],
100.0,
Some('%'),
vec![(0.0, at(0.0)), (1.0, at(100.0))],
move |v| EditorMsg::Cmyk(i, v),
)
};
column![ink(0, "C"), ink(1, "M"), ink(2, "Y"), ink(3, "K")]
} else if self.mode == EditorMode::Rgb {
let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| {
vec![(0.0, rgb_color(lo)), (1.0, rgb_color(hi))]
};
@@ -266,9 +269,8 @@ impl ColorEditor {
rows.spacing(ROW_GAP).into()
}
/// The saturation/brightness field with its hue strip, and below it H, S
/// and B on the left and the hex value on the right.
fn field_view<'a>(&self) -> Element<'a, Message> {
/// The saturation/brightness field with its hue strip.
fn field<'a>(&self) -> Element<'a, Message> {
let Hsb { h, s, b } = self.hsb;
let area = ColorArea::new(hsb_color(h, 1.0, 1.0), rgb_color(self.rgb), s, b, |s, b| {
msg(EditorMsg::Field(s, b))
@@ -285,50 +287,17 @@ impl ColorEditor {
.height(FIELD_HEIGHT)
.style(handle_style),
];
let value =
|letter: &'static str, v: f32, max: f32, unit: char, f: fn(f32) -> EditorMsg| {
row![
text(letter).size(12).font(iced::Font::MONOSPACE),
number_box(v, max, Some(unit), f),
]
.spacing(4)
.align_y(iced::Center)
};
column![
row![area, strip].spacing(12),
row![
value("H", h, 360.0, '°', EditorMsg::Hue),
value("S", s * 100.0, 100.0, '%', EditorMsg::Saturation),
value("B", b * 100.0, 100.0, '%', EditorMsg::Brightness),
Space::new().width(Length::Fill),
self.hex_field(),
]
.spacing(10)
.align_y(iced::Center),
]
// 108 + 10 matches the slider views' 3 × 26 + 2 × 14 + 12.
.spacing(10)
.into()
row![area, strip].spacing(12).into()
}
/// The hex value with `end` (the nearest index colour) at the far right.
fn hex_row<'a>(&self, end: Option<Element<'a, Message>>) -> Element<'a, Message> {
let mut hex_row = row![self.hex_field(), Space::new().width(Length::Fill)]
.spacing(8)
.align_y(iced::Center);
if let Some(end) = end {
hex_row = hex_row.push(end);
}
hex_row.into()
}
fn hex_field<'a>(&self) -> Element<'a, Message> {
let (r, g, b) = self.rgb;
let hex = self
.hex_draft
.clone()
.unwrap_or_else(|| math::hex_string(r, g, b));
row![
let mut hex_row = row![
text(t!("Hex")).size(11).style(super::picker::muted_text),
text_input("", &hex)
.on_input(|value| msg(EditorMsg::HexInput(value)))
@@ -337,10 +306,14 @@ impl ColorEditor {
.font(iced::Font::MONOSPACE)
.padding([5, 6])
.width(Length::Fixed(74.0)),
Space::new().width(Length::Fill),
]
.spacing(8)
.align_y(iced::Center)
.into()
.align_y(iced::Center);
if let Some(end) = end {
hex_row = hex_row.push(end);
}
hex_row.into()
}
}
+18
View File
@@ -59,6 +59,24 @@ pub fn rgb_to_hsb(r: u8, g: u8, b: u8) -> Hsb {
Hsb { h, s, b: max }
}
/// Cyan, magenta, yellow and black in percent, by the plain device-free
/// formula (no ICC profile): screen values, not press values.
pub fn rgb_to_cmyk(r: u8, g: u8, b: u8) -> [f32; 4] {
let max = r.max(g).max(b);
if max == 0 {
return [0.0, 0.0, 0.0, 100.0];
}
let max = f32::from(max);
let ink = |v: u8| (1.0 - f32::from(v) / max) * 100.0;
[ink(r), ink(g), ink(b), (1.0 - max / 255.0) * 100.0]
}
pub fn cmyk_to_rgb([c, m, y, k]: [f32; 4]) -> (u8, u8, u8) {
let white = 255.0 * (1.0 - k.clamp(0.0, 100.0) / 100.0);
let channel = |ink: f32| (white * (1.0 - ink.clamp(0.0, 100.0) / 100.0)).round() as u8;
(channel(c), channel(m), channel(y))
}
/// RGB of an ACI entry, or `None` outside 1–255.
pub fn aci_rgb(index: u8) -> Option<(u8, u8, u8)> {
codec::types::aci_table::aci_to_rgb(index)
+12 -20
View File
@@ -24,7 +24,7 @@ const PANEL_WIDTH: f32 = 400.0;
/// Scope for Tab / Shift+Tab, so focus cycles through the panel's own
/// fields instead of the dialogs and command line underneath.
const PANEL_ID: &str = "select-color-panel";
const BODY_HEIGHT: f32 = 352.0;
const BODY_HEIGHT: f32 = 368.0;
/// Swatches per row in the Document tab.
const DOC_COLUMNS: usize = 8;
/// Search matches shown at once in the Color books tab.
@@ -32,8 +32,8 @@ const PAGE_BARS: usize = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorPickerTab {
Document,
#[default]
Document,
Index,
TrueColor,
Books,
@@ -106,11 +106,6 @@ pub struct ColorPicker {
impl ColorPicker {
pub fn new(target: ColorPickTarget, current: AcadColor, varies: bool) -> Self {
let tab = if matches!(current, AcadColor::Rgb { .. }) {
ColorPickerTab::TrueColor
} else {
ColorPickerTab::Index
};
Self {
target,
current,
@@ -118,7 +113,7 @@ impl ColorPicker {
pending: current,
current_name: None,
pending_name: None,
tab,
tab: ColorPickerTab::Document,
editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))),
eyedropper: None,
doc: Vec::new(),
@@ -133,8 +128,7 @@ impl ColorPicker {
}
/// Fill the Document and Color books tabs. `current_name` is the
/// `BOOK$NAME` of the current colour; a colour from a loaded book opens
/// on its page.
/// `BOOK$NAME` of the current colour; the Color books tab shows its page.
pub fn with_drawing(
mut self,
mut doc: Vec<DocColor>,
@@ -147,9 +141,7 @@ impl ColorPicker {
self.recent = recent.to_vec();
self.books = books;
if let Some(name) = current_name.filter(|name| !name.is_empty()) {
if self.show_identity(&name) {
self.tab = ColorPickerTab::Books;
}
self.show_identity(&name);
self.current_name = Some(name.clone());
self.pending_name = Some(name);
}
@@ -1152,13 +1144,12 @@ mod tests {
}
#[test]
fn opens_on_the_tab_that_fits_the_current_colour() {
assert_eq!(picker(AcadColor::Index(30)).tab(), ColorPickerTab::Index);
fn opens_on_the_document_tab() {
assert_eq!(picker(AcadColor::Index(30)).tab(), ColorPickerTab::Document);
assert_eq!(
picker(AcadColor::Rgb { r: 1, g: 2, b: 3 }).tab(),
ColorPickerTab::TrueColor
ColorPickerTab::Document
);
assert_eq!(picker(AcadColor::ByLayer).tab(), ColorPickerTab::Index);
}
#[test]
@@ -1225,6 +1216,7 @@ mod tests {
assert_eq!(step_index(255, ArrowKey::Right), None);
let mut p = picker(AcadColor::Index(140));
p.update(ColorPickerMsg::Tab(ColorPickerTab::Index));
p.update(ColorPickerMsg::Arrow(ArrowKey::Right));
assert_eq!(p.pending(), AcadColor::Index(150));
}
@@ -1313,7 +1305,6 @@ mod tests {
b: 179,
})
.with_drawing(Vec::new(), &[], ral(), Some("RAL CLASSIC$RAL 5015".into()));
assert_eq!(p.tab(), ColorPickerTab::Books);
assert_eq!(p.book_selection(), Some(2));
assert_eq!(p.book_page, 1);
}
@@ -1388,6 +1379,7 @@ mod tests {
g: 191,
b: 252,
});
p.update(ColorPickerMsg::Tab(ColorPickerTab::TrueColor));
edit(&mut p, EditorMsg::Mode(EditorMode::Rgb));
let mut ui = simulator(&p);
let label = ui.find("R").expect("R label");
@@ -1489,11 +1481,11 @@ mod tests {
false,
),
(
"field",
"cmyk",
ColorPickTarget::Properties,
blue,
ColorPickerTab::TrueColor,
EditorMode::Field,
EditorMode::Cmyk,
false,
),
(