Select Color: Document and Color books tabs

Document tab: every concrete colour the drawing uses (objects, block
definitions and layers), found in one pass when the panel opens, as a
grid of swatches sortable by recently used, most used, colour and colour
number. The recently-used list is now kept per drawing, so the ribbon's
Recent row shows this drawing's colours.

Color books tab: load AutoCAD .acb colour books (remembered in the
settings on desktop), browse them page by page with the page strip,
search by name and see the RGB equivalent. A book colour is applied as
RGB plus its BOOK$NAME identity on objects and layers instead of
dropping the name.

Strings translated into all 22 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
Claude committed 2026-10-12 19:54:24 +00:00
1 parent 0904661192
commit c4597e7ba0
35 files changed
+1684 -129

No files matched your search

+3
View File
@@ -277,6 +277,8 @@ pub(super) struct DocumentTab {
/// Model-space corners of the last crossing window drawn with no command
/// running: STRETCH on that selection stretches through it (#1068).
pub(super) last_crossing_window: Option<([f64; 2], [f64; 2])>,
/// Colours picked in this drawing this session, newest first. Not saved.
pub(super) recent_colors: Vec<codec::types::Color>,
}
impl DocumentTab {
@@ -679,6 +681,7 @@ impl DocumentTab {
suspended_cmd: None,
transparent_resume: false,
last_crossing_window: None,
recent_colors: Vec::new(),
}
}
+12 -4
View File
@@ -965,9 +965,12 @@ pub(super) struct OpenCADStudio {
// ── Floating panel windows ────────────────────────────────────────────
/// The open Select Color panel, if any.
color_picker: Option<crate::ui::color_select::ColorPicker>,
/// Colour books loaded this session, read lazily from `color_book_paths`.
color_books: std::sync::Arc<Vec<crate::ui::color_select::book::ColorBook>>,
color_book_paths: Vec<String>,
color_books_read: bool,
/// Recently committed real colours, newest first.
/// ACI and True Color remain semantically distinct even when their RGB matches.
recent_colors: Vec<AcadColor>,
/// The open in-canvas modal dialog, if any (Plan B: shared overlay instead
/// of OS windows).
active_modal: Option<ModalKind>,
@@ -4168,8 +4171,11 @@ pub enum Message {
ColorPicker(crate::ui::color_select::ColorPickerMsg),
/// Close the colour picker without choosing.
CloseColorPicker,
/// Commit the colour chosen in the shared picker.
ColorWindowPick(codec::types::Color),
/// Commit the colour chosen in the shared picker, with its `BOOK$NAME`
/// when it comes from a colour book.
ColorWindowPick(codec::types::Color, Option<String>),
/// An `.acb` file was read: the book and, on desktop, where it lives.
ColorBookLoaded(Result<(crate::ui::color_select::book::ColorBook, Option<String>), String>),
/// Set a block/linetype Handle field on the selected dim style from a
/// dropdown of available block-records / linetypes (by name).
DsSetHandle {
@@ -4601,7 +4607,9 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
web_autosave_clear_after: Vec::new(),
color_picker: None,
recent_colors: Vec::new(),
color_books: Default::default(),
color_book_paths: Vec::new(),
color_books_read: false,
active_modal: None,
hyperlink_editor_handles: Vec::new(),
hyperlink_editor_url: String::new(),
+4
View File
@@ -619,6 +619,9 @@ pub struct UserSettings {
/// Blocks palette: library folders or drawings, last used first.
#[serde(default)]
pub block_libraries: Vec<String>,
/// Colour book (.acb) files loaded in the Select Color panel.
#[serde(default)]
pub color_books: Vec<String>,
/// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons,
/// 4 details, 5 list.
#[serde(default = "default_block_view")]
@@ -901,6 +904,7 @@ impl Default for UserSettings {
block_recent: Vec::new(),
block_favorites: Vec::new(),
block_libraries: Vec::new(),
color_books: Vec::new(),
block_palette_view: 1,
block_insert: BlockInsertOptions::default(),
block_mru_list: 50,
+2
View File
@@ -860,6 +860,7 @@ impl OpenCADStudio {
block_recent: self.block_palette.recent.clone(),
block_favorites: self.block_palette.favorites.clone(),
block_libraries: self.block_palette.libraries.clone(),
color_books: self.color_book_paths.clone(),
block_palette_view: self.block_palette.view as u8,
block_insert: self.block_palette.options.clone(),
block_mru_list: self.block_mru_list,
@@ -1025,6 +1026,7 @@ impl OpenCADStudio {
self.block_palette.recent = s.block_recent.clone();
self.block_palette.favorites = s.block_favorites.clone();
self.block_palette.libraries = s.block_libraries.clone();
self.color_book_paths = s.color_books.clone();
self.block_palette.view =
crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view);
self.block_palette.set_options(s.block_insert.clone());
+181 -69
View File
@@ -4180,38 +4180,7 @@ impl OpenCADStudio {
Task::none()
}
Message::LayerColorSet(color) => {
let i = self.active_tab;
// Apply to every selected layer (multi-select), not just one.
let names = self.selected_layer_names(i);
if !names.is_empty() {
let undo = self.begin_layer_undo(i, "LAYER COLOR", &names);
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.color = color;
dl.color_name = None;
dl.book_name = None;
}
}
for pl in self.tabs[i].layers.layers.iter_mut() {
if names.contains(&pl.name) {
pl.color = color;
pl.color_name = None;
pl.book_name = None;
}
}
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
// ByLayer color is baked into the cached wires at
// tessellation time, so bump the geometry epoch to
// invalidate the wire cache and repaint with the new color.
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].layers.color_picker_row = None;
self.tabs[i].layers.color_full_palette = false;
self.sync_ribbon_layers();
}
Task::none()
}
Message::LayerColorSet(color) => self.set_layer_color(color, None),
Message::LayerLinetypeSet(lt) => {
let i = self.active_tab;
@@ -7264,22 +7233,7 @@ impl OpenCADStudio {
Task::none()
}
Message::PropColorChanged(color) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if handles.is_empty() {
let task = self.on_ribbon_color_changed(color);
self.refresh_properties();
return task;
}
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::view::dispatch::apply_color(entity, color);
}
});
self.tabs[i].properties.color_picker_open = false;
Task::none()
}
Message::PropColorChanged(color) => self.set_property_color(color, None),
Message::PropLwChanged(lw) => {
let i = self.active_tab;
@@ -11605,10 +11559,19 @@ impl OpenCADStudio {
) {
self.graphic_attributes.open_menu = None;
}
let (current, varies) = self.color_picker_current(&target, color);
self.color_picker = Some(crate::ui::color_select::ColorPicker::new(
target, current, varies,
));
let (current, varies, name) = self.color_picker_current(&target, color);
let mut picker = crate::ui::color_select::ColorPicker::new(target, current, varies);
if !matches!(picker.target, crate::app::ColorPickTarget::Interface(_)) {
self.read_color_books();
let tab = &self.tabs[self.active_tab];
picker = picker.with_drawing(
crate::ui::color_select::document::collect(&tab.scene.document),
&tab.recent_colors,
std::sync::Arc::clone(&self.color_books),
name,
);
}
self.color_picker = Some(picker);
self.modal_offset = iced::Vector::ZERO;
self.ds_color_open = None;
self.mls_color_open = None;
@@ -11628,9 +11591,28 @@ impl OpenCADStudio {
return Task::none();
};
match picker.update(msg) {
Some(crate::ui::color_select::Outcome::Commit(color)) => {
self.update(Message::ColorWindowPick(color))
Some(crate::ui::color_select::Outcome::Commit(color, name)) => {
self.update(Message::ColorWindowPick(color, name))
}
Some(crate::ui::color_select::Outcome::LoadBook) => Task::perform(
async {
let file = crate::sys::file_dialog()
.add_filter(crate::t!("Color book").as_ref(), &["acb", "ACB"])
.pick_file()
.await
.ok_or_else(|| "Cancelled".to_string())?;
let book = crate::ui::color_select::book::parse(
&String::from_utf8_lossy(&file.read().await),
)?;
// The web build has no paths to read again later.
#[cfg(not(target_arch = "wasm32"))]
let path = Some(crate::sys::handle_path(&file).display().to_string());
#[cfg(target_arch = "wasm32")]
let path = None;
Ok((book, path))
},
Message::ColorBookLoaded,
),
Some(crate::ui::color_select::Outcome::Cancel) => {
self.color_picker = None;
Task::none()
@@ -11656,7 +11638,7 @@ impl OpenCADStudio {
Task::none()
}
Message::ColorWindowPick(color) => {
Message::ColorWindowPick(color, name) => {
// Interface colours are not drawing colours: keep them out of
// the recent list.
if !matches!(
@@ -11665,7 +11647,35 @@ impl OpenCADStudio {
) {
self.note_recent_color(color);
}
self.on_color_window_pick(color)
self.on_color_window_pick(color, name)
}
Message::ColorBookLoaded(Ok((book, path))) => {
// A book loaded again replaces the earlier copy.
let books = std::sync::Arc::make_mut(&mut self.color_books);
let index = match books.iter().position(|b| b.name == book.name) {
Some(index) => {
books[index] = book;
index
}
None => {
books.push(book);
books.len() - 1
}
};
if let Some(path) = path.filter(|path| !self.color_book_paths.contains(path)) {
self.color_book_paths.push(path);
self.save_config();
}
if let Some(picker) = self.color_picker.as_mut() {
picker.show_book(std::sync::Arc::clone(&self.color_books), index);
}
Task::none()
}
Message::ColorBookLoaded(Err(error)) => {
if let Some(picker) = self.color_picker.as_mut().filter(|_| error != "Cancelled") {
picker.set_status(error);
}
Task::none()
}
Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value),
}
@@ -11674,16 +11684,33 @@ impl OpenCADStudio {
/// The colour the Select Color panel shows as "Current", and whether the
/// selection it edits has mixed colours. A mixed selection starts from the
/// colour of its most recently drawn object (the highest handle).
/// Also returns the colour-book identity (`BOOK$NAME`) of that colour.
fn color_picker_current(
&self,
target: &crate::app::ColorPickTarget,
color: codec::types::Color,
) -> (codec::types::Color, bool) {
if !matches!(target, crate::app::ColorPickTarget::Properties) {
return (color, false);
}
) -> (codec::types::Color, bool, Option<String>) {
let i = self.active_tab;
let document = &self.tabs[i].scene.document;
let name_of = |handle| {
document
.get_entity(handle)
.and_then(|entity| entity.common().color_name.clone())
};
match target {
crate::app::ColorPickTarget::Properties => {}
crate::app::ColorPickTarget::Layer(layer) => {
let name = document.layers.get(layer).and_then(|layer| {
match (&layer.book_name, &layer.color_name) {
(Some(book), Some(name)) => Some(format!("{book}${name}")),
(None, Some(name)) => Some(name.clone()),
_ => None,
}
});
return (color, false, name);
}
_ => return (color, false, None),
}
let mut colors = self
.property_target_handles(i)
.into_iter()
@@ -11693,15 +11720,99 @@ impl OpenCADStudio {
.map(|entity| (handle, entity.common().color))
})
.collect::<Vec<_>>();
let Some(&(_, first)) = colors.first() else {
return (color, false);
let Some(&(first_handle, first)) = colors.first() else {
return (color, false, None);
};
if colors.iter().all(|&(_, c)| c == first) {
return (first, false);
return (first, false, name_of(first_handle));
}
colors.sort_by_key(|&(handle, _)| handle);
let newest = colors.last().map_or(color, |&(_, c)| c);
(newest, true)
let (newest_handle, newest) = colors.last().copied().unwrap_or((first_handle, color));
(newest, true, name_of(newest_handle))
}
/// Read the colour books remembered in the settings, once per session.
fn read_color_books(&mut self) {
if std::mem::replace(&mut self.color_books_read, true) {
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
let books: Vec<_> = self
.color_book_paths
.iter()
.filter_map(|path| std::fs::read_to_string(path).ok())
.filter_map(|text| crate::ui::color_select::book::parse(&text).ok())
.collect();
self.color_books = std::sync::Arc::new(books);
}
}
/// Colour the selected objects (or set the current colour when nothing
/// is selected). `name` is a colour-book identity, `BOOK$NAME`.
pub(super) fn set_property_color(
&mut self,
color: codec::types::Color,
name: Option<String>,
) -> Task<Message> {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if handles.is_empty() {
let task = self.on_ribbon_color_changed(color);
self.refresh_properties();
return task;
}
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::view::dispatch::apply_named_color(entity, color, name.clone());
}
});
self.tabs[i].properties.color_picker_open = false;
Task::none()
}
/// Colour every selected layer (multi-select), not just one. `name` is a
/// colour-book identity, `BOOK$NAME`.
pub(super) fn set_layer_color(
&mut self,
color: codec::types::Color,
name: Option<&str>,
) -> Task<Message> {
let i = self.active_tab;
// Layers keep the book and the colour name apart.
let (book_name, color_name) = match name.map(|name| name.split_once('$')) {
Some(Some((book, name))) => (Some(book), Some(name)),
Some(None) => (None, name),
None => (None, None),
};
let names = self.selected_layer_names(i);
if !names.is_empty() {
let undo = self.begin_layer_undo(i, "LAYER COLOR", &names);
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.color = color;
dl.color_name = color_name.map(str::to_string);
dl.book_name = book_name.map(str::to_string);
}
}
for pl in self.tabs[i].layers.layers.iter_mut() {
if names.contains(&pl.name) {
pl.color = color;
pl.color_name = color_name.map(str::to_string);
pl.book_name = book_name.map(str::to_string);
}
}
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
// ByLayer color is baked into the cached wires at
// tessellation time, so bump the geometry epoch to
// invalidate the wire cache and repaint with the new color.
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].layers.color_picker_row = None;
self.tabs[i].layers.color_full_palette = false;
self.sync_ribbon_layers();
}
Task::none()
}
pub(crate) fn note_recent_color(&mut self, color: codec::types::Color) {
@@ -11712,12 +11823,13 @@ impl OpenCADStudio {
codec::types::Color::Index(_) | codec::types::Color::Rgb { .. }
) {
// No duplicates: selecting an existing colour moves it to the front.
if let Some(pos) = self.recent_colors.iter().position(|c| c == &color) {
self.recent_colors.remove(pos);
let recent = &mut self.tabs[self.active_tab].recent_colors;
if let Some(pos) = recent.iter().position(|c| c == &color) {
recent.remove(pos);
}
self.recent_colors.insert(0, color);
self.recent_colors.truncate(12);
recent.insert(0, color);
recent.truncate(12);
}
}
+11 -5
View File
@@ -1136,7 +1136,11 @@ impl OpenCADStudio {
Task::none()
}
pub(super) fn on_color_window_pick(&mut self, color: codec::types::Color) -> Task<Message> {
pub(super) fn on_color_window_pick(
&mut self,
color: codec::types::Color,
name: Option<String>,
) -> Task<Message> {
if let Some(crate::app::ColorPickTarget::Interface(target)) = self
.color_picker
.as_ref()
@@ -1201,7 +1205,9 @@ impl OpenCADStudio {
field: f,
value: s,
}),
Some(crate::app::ColorPickTarget::Properties) => Some(Message::PropColorChanged(color)),
Some(crate::app::ColorPickTarget::Properties) => {
return self.set_property_color(color, name);
}
Some(crate::app::ColorPickTarget::PropertiesBg) => {
Some(Message::PropBgColorChanged(color))
}
@@ -1210,12 +1216,12 @@ impl OpenCADStudio {
}
Some(crate::app::ColorPickTarget::MText) => Some(Message::MTextColorChanged(color)),
Some(crate::app::ColorPickTarget::Ribbon) => Some(Message::RibbonColorChanged(color)),
Some(crate::app::ColorPickTarget::Layer(name)) => {
Some(crate::app::ColorPickTarget::Layer(layer)) => {
let i = self.active_tab;
match self.layer_panel_index(i, &name) {
match self.layer_panel_index(i, &layer) {
Some(idx) => {
self.tabs[i].layers.selected = Some(idx);
Some(Message::LayerColorSet(color))
return self.set_layer_color(color, name.as_deref());
}
None => None,
}
+1 -1
View File
@@ -2187,7 +2187,7 @@ bg={bg_ms:.1}ms n={view_count}"
&redo_labels,
self.win_size,
self.tabs[self.active_tab].is_start,
&self.recent_colors,
&self.tabs[self.active_tab].recent_colors,
)
.unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into())
};
+15
View File
@@ -4720,6 +4720,21 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Hex" => Some(("common", "color-hex")),
"Nearest index" => Some(("common", "nearest-index-color")),
"Exact index match" => Some(("common", "exact-index-match")),
"Recently used" => Some(("common", "color-recently-used")),
"Most used" => Some(("common", "color-most-used")),
"Color number" => Some(("common", "color-number")),
"Colors in this drawing" => Some(("common", "color-in-drawing")),
"Sort" => Some(("common", "color-sort")),
"No colors in this drawing yet" => Some(("common", "color-no-drawing-colors")),
"Color Books" => Some(("common", "color-books")),
"No color books loaded" => Some(("common", "color-no-books")),
"Load color book (.acb)" => Some(("common", "color-load-book")),
"Color book" => Some(("common", "color-book-label")),
"RGB equivalent" => Some(("common", "color-rgb-equivalent")),
"No colors match" => Some(("common", "color-no-match")),
"Not a color book" => Some(("common", "color-not-a-book")),
"This color book is encrypted and cannot be read" => Some(("common", "color-book-encrypted")),
"The color book has no colors" => Some(("common", "color-book-empty")),
"CAD Color Index (ACI) 10–249" => Some(("common", "cad-color-index-aci-10-249")),
"Grayscale 250–255" => Some(("common", "grayscale-250-255")),
"Isometric drafting" => Some(("common", "isometric-drafting")),
+6
View File
@@ -322,6 +322,12 @@ pub fn apply_color(entity: &mut EntityType, color: AcadColor) {
common.color_book_handle = None;
}
/// Like [`apply_color`], but keeps a colour-book identity (`BOOK$NAME`).
pub fn apply_named_color(entity: &mut EntityType, color: AcadColor, name: Option<String>) {
apply_color(entity, color);
entity.common_mut().color_name = name;
}
pub fn apply_line_weight(entity: &mut EntityType, lw: LineWeight) {
entity.as_entity_mut().set_line_weight(lw);
}
+176
View File
@@ -0,0 +1,176 @@
//! Colour books: AutoCAD `.acb` files.
//!
//! An `.acb` is XML: a `bookName` and `colorPage`s of `colorEntry`s, each a
//! `colorName` and an `RGB8` value. Books from some vendors encrypt their
//! values; those cannot be read.
/// One named colour.
#[derive(Debug, Clone, PartialEq)]
pub struct BookColor {
pub name: String,
pub rgb: (u8, u8, u8),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ColorBook {
pub name: String,
/// Every colour, page after page.
pub colors: Vec<BookColor>,
/// Where each page starts in `colors`.
page_starts: Vec<usize>,
}
/// Pages for a book without `colorPage`s, as AutoCAD shows them.
const PAGE_SIZE: usize = 7;
impl ColorBook {
pub fn page_count(&self) -> usize {
self.page_starts.len()
}
/// The colours of one page, and the index of its first colour.
pub fn page(&self, page: usize) -> (usize, &[BookColor]) {
let start = self.page_starts.get(page).copied().unwrap_or(0);
let end = self
.page_starts
.get(page + 1)
.copied()
.unwrap_or(self.colors.len());
(start, &self.colors[start..end])
}
/// The page holding colour `index`.
pub fn page_of(&self, index: usize) -> usize {
self.page_starts
.partition_point(|&start| start <= index)
.saturating_sub(1)
}
/// The colour with this name, as stored after the `$` of `BOOK$NAME`.
pub fn find(&self, name: &str) -> Option<usize> {
self.colors.iter().position(|c| c.name == name)
}
/// The `BOOK$NAME` identity objects and layers store.
pub fn identity(&self, index: usize) -> String {
format!("{}${}", self.name, self.colors[index].name)
}
}
/// Read an `.acb` file's text.
pub fn parse(text: &str) -> Result<ColorBook, String> {
let doc = roxmltree::Document::parse(text.trim_start_matches('\u{feff}'))
.map_err(|e| e.to_string())?;
let root = doc.root_element();
if !root.has_tag_name("colorBook") {
return Err(crate::t!("Not a color book").into_owned());
}
let mut book = ColorBook {
name: text_of(root, "bookName").unwrap_or_default().to_string(),
colors: Vec::new(),
page_starts: Vec::new(),
};
let mut encrypted = false;
let mut read_entries = |parent: roxmltree::Node<'_, '_>, book: &mut ColorBook| {
for entry in parent.children().filter(|n| n.has_tag_name("colorEntry")) {
let rgb = child(entry, "RGB8").and_then(|rgb| {
let channel = |tag| text_of(rgb, tag)?.parse::<u8>().ok();
Some((channel("red")?, channel("green")?, channel("blue")?))
});
match (text_of(entry, "colorName"), rgb) {
(Some(name), Some(rgb)) => book.colors.push(BookColor {
name: name.to_string(),
rgb,
}),
_ => encrypted = true,
}
}
};
let mut pages = root
.children()
.filter(|n| n.has_tag_name("colorPage"))
.peekable();
if pages.peek().is_some() {
for page in pages {
let start = book.colors.len();
read_entries(page, &mut book);
if book.colors.len() > start {
book.page_starts.push(start);
}
}
} else {
read_entries(root, &mut book);
book.page_starts = (0..book.colors.len()).step_by(PAGE_SIZE).collect();
}
if book.colors.is_empty() {
return Err(if encrypted {
crate::t!("This color book is encrypted and cannot be read")
} else {
crate::t!("The color book has no colors")
}
.into_owned());
}
Ok(book)
}
fn child<'a, 'i>(node: roxmltree::Node<'a, 'i>, tag: &str) -> Option<roxmltree::Node<'a, 'i>> {
node.children().find(|n| n.has_tag_name(tag))
}
fn text_of<'a>(node: roxmltree::Node<'a, '_>, tag: &str) -> Option<&'a str> {
child(node, tag).and_then(|n| n.text()).map(str::trim)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, (r, g, b): (u8, u8, u8)) -> String {
format!(
"<colorEntry><colorName>{name}</colorName>\
<RGB8><red>{r}</red><green>{g}</green><blue>{b}</blue></RGB8></colorEntry>"
)
}
#[test]
fn reads_pages_and_colours() {
let text = format!(
"\u{feff}<?xml version=\"1.0\"?><colorBook><bookName>RAL CLASSIC</bookName>\
<colorPage><pageColor><RGB8><red>1</red><green>2</green><blue>3</blue></RGB8></pageColor>{}{}</colorPage>\
<colorPage>{}</colorPage></colorBook>",
entry("RAL 1000", (190, 189, 127)),
entry("RAL 1001", (194, 176, 120)),
entry("RAL 5015", (34, 113, 179)),
);
let book = parse(&text).unwrap();
assert_eq!(book.name, "RAL CLASSIC");
assert_eq!(book.page_count(), 2);
assert_eq!(book.page(1), (2, &book.colors[2..]));
assert_eq!(book.page_of(1), 0);
assert_eq!(book.page_of(2), 1);
let blue = book.find("RAL 5015").unwrap();
assert_eq!(book.colors[blue].rgb, (34, 113, 179));
assert_eq!(book.identity(blue), "RAL CLASSIC$RAL 5015");
}
#[test]
fn a_book_without_pages_gets_pages_of_seven() {
let entries: String = (0..9).map(|i| entry(&format!("C{i}"), (i, i, i))).collect();
let book = parse(&format!(
"<colorBook><bookName>B</bookName>{entries}</colorBook>"
))
.unwrap();
assert_eq!(book.page_count(), 2);
assert_eq!(book.page(1).1.len(), 2);
}
#[test]
fn rejects_unreadable_books() {
assert!(parse("not xml").is_err());
assert!(parse("<palette/>").is_err());
let encrypted = "<colorBook><bookName>P</bookName><colorPage><colorEntry>\
<colorName>X</colorName><RGB8Encrypt>AbC</RGB8Encrypt></colorEntry>\
</colorPage></colorBook>";
assert!(parse(encrypted).is_err());
}
}
+233
View File
@@ -0,0 +1,233 @@
//! The colours a drawing uses, for the Document tab.
//!
//! One pass over every object (block definitions included) and every layer
//! when the panel opens. Keys borrow from the drawing, so the pass allocates
//! only per distinct colour, never per object.
use super::math;
use codec::types::{Color as AcadColor, Handle};
use rustc_hash::FxHashMap;
/// One distinct colour: a concrete colour plus, for a colour-book colour,
/// its `BOOK$NAME` identity.
#[derive(Debug, Clone, PartialEq)]
pub struct DocColor {
pub color: AcadColor,
pub name: Option<String>,
/// Objects and layers using it. Only used for sorting, never shown.
uses: u32,
/// The newest object or layer using it.
newest: Handle,
}
impl DocColor {
/// The few characters drawn on the swatch: the index number, `RGB`, or
/// the first word of the colour book.
pub fn tag(&self) -> String {
match (&self.name, self.color) {
(Some(name), _) => name
.split(['$', ' '])
.next()
.unwrap_or_default()
.chars()
.take(5)
.collect(),
(None, AcadColor::Index(i)) => i.to_string(),
_ => "RGB".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DocSort {
#[default]
Recent,
Usage,
Hue,
Number,
}
impl DocSort {
pub const ALL: [DocSort; 4] = [
DocSort::Recent,
DocSort::Usage,
DocSort::Hue,
DocSort::Number,
];
}
impl std::fmt::Display for DocSort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&match self {
DocSort::Recent => crate::t!("Recently used"),
DocSort::Usage => crate::t!("Most used"),
DocSort::Hue => crate::t!("Color"),
DocSort::Number => crate::t!("Color number"),
})
}
}
/// A colour as a hash key: index colours keep their number, true colours
/// sit above 255.
fn key(color: AcadColor) -> Option<u32> {
match color {
AcadColor::Index(i) => Some(u32::from(i)),
AcadColor::Rgb { r, g, b } => {
Some(1 << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b))
}
_ => None,
}
}
/// Every concrete colour in the drawing. ByLayer, ByBlock and None are
/// states rather than colours and are skipped.
pub fn collect(document: &codec::CadDocument) -> Vec<DocColor> {
// Layers keep book and colour name apart; join them like objects do.
let layer_names: Vec<Option<String>> = document
.layers
.iter()
.map(|layer| match (&layer.book_name, &layer.color_name) {
(Some(book), Some(name)) => Some(format!("{book}${name}")),
(None, Some(name)) => Some(name.clone()),
_ => None,
})
.collect();
let objects = document.entities().map(|entity| {
let common = entity.common();
(common.color, common.color_name.as_deref(), common.handle)
});
let layers = document
.layers
.iter()
.zip(&layer_names)
.map(|(layer, name)| (layer.color, name.as_deref(), layer.handle));
tally(objects.chain(layers))
}
fn tally<'a>(uses: impl Iterator<Item = (AcadColor, Option<&'a str>, Handle)>) -> Vec<DocColor> {
let mut index: FxHashMap<(u32, Option<&str>), usize> = FxHashMap::default();
let mut colors: Vec<DocColor> = Vec::new();
for (color, name, handle) in uses {
let Some(k) = key(color) else { continue };
let name = name.filter(|name| !name.is_empty());
let slot = *index.entry((k, name)).or_insert_with(|| {
colors.push(DocColor {
color,
name: name.map(str::to_string),
uses: 0,
newest: handle,
});
colors.len() - 1
});
let entry = &mut colors[slot];
entry.uses += 1;
entry.newest = entry.newest.max(handle);
}
colors
}
/// Sort for display. `recent` is this drawing's pick history, newest first;
/// colours not picked this session follow, newest object first.
pub fn sort(colors: &mut [DocColor], by: DocSort, recent: &[AcadColor]) {
let rank = |c: &DocColor| {
recent
.iter()
.position(|&r| r == c.color)
.unwrap_or(usize::MAX)
};
match by {
DocSort::Recent => {
colors.sort_by(|a, b| rank(a).cmp(&rank(b)).then(b.newest.cmp(&a.newest)))
}
DocSort::Usage => colors.sort_by(|a, b| b.uses.cmp(&a.uses).then(b.newest.cmp(&a.newest))),
DocSort::Hue => colors.sort_by_cached_key(|c| {
let (r, g, b) = math::color_rgb(c.color).unwrap_or_default();
let hsb = math::rgb_to_hsb(r, g, b);
// Grays after the colours; within a hue band, light to dark.
let gray = hsb.s < 0.12 || hsb.b < 0.08;
let band = if gray {
0
} else {
(hsb.h / 15.0).round() as u32
};
(gray, band, std::cmp::Reverse((hsb.b * 1000.0) as u32))
}),
DocSort::Number => colors.sort_by(|a, b| {
let kind = |c: &DocColor| match (&c.name, c.color) {
(None, AcadColor::Index(_)) => 0,
(None, _) => 1,
_ => 2,
};
kind(a)
.cmp(&kind(b))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| key(a.color).cmp(&key(b.color)))
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
const RED: AcadColor = AcadColor::Index(1);
const BLUE: AcadColor = AcadColor::Index(5);
const SKY: AcadColor = AcadColor::Rgb {
r: 57,
g: 191,
b: 252,
};
fn sample() -> Vec<DocColor> {
let h = Handle::new;
tally(
[
(RED, None, h(1)),
(RED, None, h(2)),
(RED, None, h(3)),
(AcadColor::ByLayer, None, h(4)),
(SKY, None, h(5)),
(SKY, Some("RAL CLASSIC$RAL 5015"), h(6)),
(BLUE, Some(""), h(7)),
]
.into_iter(),
)
}
#[test]
fn collects_each_concrete_colour_once() {
let colors = sample();
assert_eq!(colors.len(), 4, "ByLayer skipped, book colour kept apart");
assert_eq!(colors[0].uses, 3);
assert_eq!(colors[0].newest, Handle::new(3));
assert_eq!(colors[3].name, None, "an empty name is no name");
assert_eq!(colors[2].tag(), "RAL");
assert_eq!(colors[0].tag(), "1");
assert_eq!(colors[1].tag(), "RGB");
}
#[test]
fn sorts_four_ways() {
let order = |by, recent: &[AcadColor]| {
let mut colors = sample();
sort(&mut colors, by, recent);
colors
.iter()
.map(|c| (c.color, c.name.is_some()))
.collect::<Vec<_>>()
};
// Picked this session first, then newest object first.
assert_eq!(
order(DocSort::Recent, &[RED]),
[(RED, false), (BLUE, false), (SKY, true), (SKY, false)]
);
assert_eq!(order(DocSort::Usage, &[])[0], (RED, false));
assert_eq!(
order(DocSort::Number, &[]),
[(RED, false), (BLUE, false), (SKY, false), (SKY, true)]
);
// Red (hue 0) before sky blue (hue ~199) before blue (hue 240).
assert_eq!(order(DocSort::Hue, &[])[0], (RED, false));
assert_eq!(order(DocSort::Hue, &[])[3], (BLUE, false));
}
}
+2
View File
@@ -3,6 +3,8 @@
//! properties panel and every style editor so colour selection looks and
//! behaves the same everywhere.
pub mod book;
pub mod document;
pub mod editor;
pub mod eyedropper;
pub mod math;
+708 -50
View File
@@ -5,6 +5,8 @@
//! Opened for an interface colour (any colour in Options) it shows only the
//! colour editor: no tabs, no index colours.
use super::book::ColorBook;
use super::document::{self, DocColor, DocSort};
use super::editor::{rgb_color, ColorEditor, EditorMsg};
use super::eyedropper::{Eyedropper, EyedropperMsg};
use super::math;
@@ -12,20 +14,29 @@ use crate::app::{ArrowKey, ColorPickTarget, Message};
use crate::t;
use crate::ui::properties::acad_color_display;
use codec::types::Color as AcadColor;
use iced::widget::{button, column, container, row, text, Space};
use iced::widget::{
button, column, container, mouse_area, pick_list, row, scrollable, svg, text, text_input, Space,
};
use iced::{Background, Border, Color, Element, Length, Theme};
use std::sync::Arc;
const PANEL_WIDTH: f32 = 400.0;
/// Scope for Tab / Shift+Tab, so focus cycles through the panel's own
/// fields instead of the dialogs and command line underneath.
const PANEL_ID: &str = "select-color-panel";
const BODY_HEIGHT: f32 = 352.0;
/// Swatches per row in the Document tab.
const DOC_COLUMNS: usize = 8;
/// Search matches shown at once in the Color books tab.
const PAGE_BARS: usize = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorPickerTab {
Document,
#[default]
Index,
TrueColor,
Books,
}
#[derive(Debug, Clone)]
@@ -40,17 +51,32 @@ pub enum ColorPickerMsg {
UseNearest,
Arrow(ArrowKey),
Eyedropper(EyedropperMsg),
/// A colour of the Document tab, by its place in the list.
PickDocument(usize),
DocSort(DocSort),
/// Show another loaded colour book.
Book(usize),
BookPage(usize),
/// Scrolling over the page: one page per notch.
BookScroll(f32),
BookQuery(String),
/// A colour of the shown book, by its index in the book.
PickBook(usize),
LoadBook,
Ok,
Cancel,
}
/// What the caller has to do after an update.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
Commit(AcadColor),
/// The colour, and its `BOOK$NAME` when it comes from a colour book.
Commit(AcadColor, Option<String>),
Cancel,
/// Capture the window for the eyedropper.
Screenshot,
/// Ask for an `.acb` file.
LoadBook,
}
pub struct ColorPicker {
@@ -60,9 +86,22 @@ pub struct ColorPicker {
current: AcadColor,
varies: bool,
pending: AcadColor,
/// `BOOK$NAME` of the current and the new colour, for colour-book colours.
current_name: Option<String>,
pending_name: Option<String>,
tab: ColorPickerTab,
editor: ColorEditor,
eyedropper: Option<Eyedropper>,
doc: Vec<DocColor>,
doc_sort: DocSort,
/// This drawing's colours picked this session, newest first.
recent: Vec<AcadColor>,
books: Arc<Vec<ColorBook>>,
book: usize,
book_page: usize,
book_query: String,
/// Why the last colour book did not load.
status: Option<String>,
}
impl ColorPicker {
@@ -77,12 +116,85 @@ impl ColorPicker {
current,
varies,
pending: current,
current_name: None,
pending_name: None,
tab,
editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))),
eyedropper: None,
doc: Vec::new(),
doc_sort: DocSort::default(),
recent: Vec::new(),
books: Arc::default(),
book: 0,
book_page: 0,
book_query: String::new(),
status: None,
}
}
/// Fill the Document and Color books tabs. `current_name` is the
/// `BOOK$NAME` of the current colour; a colour from a loaded book opens
/// on its page.
pub fn with_drawing(
mut self,
mut doc: Vec<DocColor>,
recent: &[AcadColor],
books: Arc<Vec<ColorBook>>,
current_name: Option<String>,
) -> Self {
document::sort(&mut doc, self.doc_sort, recent);
self.doc = doc;
self.recent = recent.to_vec();
self.books = books;
if let Some(name) = current_name.filter(|name| !name.is_empty()) {
if self.show_identity(&name) {
self.tab = ColorPickerTab::Books;
}
self.current_name = Some(name.clone());
self.pending_name = Some(name);
}
self
}
/// The loaded books changed; show book `index`.
pub fn show_book(&mut self, books: Arc<Vec<ColorBook>>, index: usize) {
self.books = books;
self.book = index;
self.book_page = 0;
self.book_query.clear();
self.status = None;
self.tab = ColorPickerTab::Books;
}
pub fn set_status(&mut self, status: String) {
self.status = Some(status);
}
/// Turn to the page holding `BOOK$NAME`. False when no loaded book has it.
fn show_identity(&mut self, identity: &str) -> bool {
let Some((book_name, name)) = identity.split_once('$') else {
return false;
};
for (i, book) in self.books.iter().enumerate() {
if let Some(color) = book.find(name).filter(|_| book.name == book_name) {
self.book = i;
self.book_page = book.page_of(color);
return true;
}
}
false
}
/// The colour of the shown book that is the new colour, if any.
fn book_selection(&self) -> Option<usize> {
let (book_name, name) = self.pending_name.as_deref()?.split_once('$')?;
let book = self
.books
.get(self.book)
.filter(|book| book.name == book_name)?;
book.find(name)
}
pub fn pending(&self) -> AcadColor {
self.pending
}
@@ -98,6 +210,7 @@ impl ColorPicker {
fn set_pending(&mut self, color: AcadColor) {
self.pending = color;
self.pending_name = None;
if let Some(rgb) = math::color_rgb(color) {
self.editor.set_rgb(rgb);
}
@@ -131,24 +244,82 @@ impl ColorPicker {
match msg {
ColorPickerMsg::Tab(tab) => self.tab = tab,
ColorPickerMsg::Pick(color) => self.set_pending(color),
ColorPickerMsg::Revert => self.set_pending(self.current),
ColorPickerMsg::Revert => {
self.set_pending(self.current);
self.pending_name = self.current_name.clone();
}
ColorPickerMsg::Editor(editor) => {
if self.editor.update(editor) {
let (r, g, b) = self.editor.rgb();
self.pending = AcadColor::Rgb { r, g, b };
self.pending_name = None;
}
}
ColorPickerMsg::UseNearest => {
let (r, g, b) = self.rgb();
self.set_pending(AcadColor::Index(math::nearest_aci(r, g, b)));
}
ColorPickerMsg::Arrow(direction) => {
if let (ColorPickerTab::Index, AcadColor::Index(index)) = (self.tab, self.pending) {
ColorPickerMsg::Arrow(direction) => match (self.tab, self.pending) {
(ColorPickerTab::Index, AcadColor::Index(index)) => {
if let Some(next) = step_index(index, direction) {
self.set_pending(AcadColor::Index(next));
}
}
(ColorPickerTab::Document, _) => {
let at = self
.doc
.iter()
.position(|c| c.color == self.pending && c.name == self.pending_name);
if let Some(next) = step_list(at, self.doc.len(), DOC_COLUMNS, direction) {
self.update(ColorPickerMsg::PickDocument(next));
}
}
(ColorPickerTab::Books, _) => {
let len = self
.books
.get(self.book)
.map_or(0, |book| book.colors.len());
if let Some(next) = step_list(self.book_selection(), len, 1, direction) {
self.pick_book(next);
}
}
_ => {}
},
ColorPickerMsg::PickDocument(i) => {
if let Some(entry) = self.doc.get(i) {
let (color, name) = (entry.color, entry.name.clone());
self.set_pending(color);
self.pending_name = name;
}
}
ColorPickerMsg::DocSort(by) => {
self.doc_sort = by;
document::sort(&mut self.doc, by, &self.recent);
}
ColorPickerMsg::Book(i) => {
if i < self.books.len() {
self.book = i;
self.book_page = 0;
self.book_query.clear();
}
}
ColorPickerMsg::BookPage(page) => {
if let Some(book) = self.books.get(self.book) {
self.book_page = page.min(book.page_count().saturating_sub(1));
self.book_query.clear();
}
}
ColorPickerMsg::BookScroll(delta) => {
let page = if delta < 0.0 {
self.book_page + 1
} else {
self.book_page.saturating_sub(1)
};
self.update(ColorPickerMsg::BookPage(page));
}
ColorPickerMsg::BookQuery(query) => self.book_query = query,
ColorPickerMsg::PickBook(i) => self.pick_book(i),
ColorPickerMsg::LoadBook => return Some(Outcome::LoadBook),
ColorPickerMsg::Eyedropper(EyedropperMsg::Start) => {
self.eyedropper = Some(Eyedropper::default());
}
@@ -156,13 +327,26 @@ impl ColorPicker {
ColorPickerMsg::Ok => {
// Apply a value still being typed, as if Enter had been pressed.
self.update(ColorPickerMsg::Editor(EditorMsg::HexSubmit));
return Some(Outcome::Commit(self.pending));
return Some(Outcome::Commit(self.pending, self.pending_name.clone()));
}
ColorPickerMsg::Cancel => return Some(Outcome::Cancel),
}
None
}
fn pick_book(&mut self, index: usize) {
let books = Arc::clone(&self.books);
let Some(book) = books.get(self.book) else {
return;
};
if let Some(color) = book.colors.get(index) {
let (r, g, b) = color.rgb;
self.set_pending(AcadColor::Rgb { r, g, b });
self.pending_name = Some(book.identity(index));
self.book_page = book.page_of(index);
}
}
/// The full-window eyedropper layer, while it is active.
pub fn eyedropper_view(&self) -> Option<Element<'_, Message>> {
self.eyedropper.as_ref().map(Eyedropper::view)
@@ -170,19 +354,33 @@ impl ColorPicker {
pub fn view(&self) -> Element<'_, Message> {
let editor_only = self.editor_only();
let body = if editor_only || self.tab == ColorPickerTab::TrueColor {
self.true_color_tab()
} else {
self.index_tab()
let body = match self.tab {
_ if editor_only => self.true_color_tab(),
ColorPickerTab::Document => self.document_tab(),
ColorPickerTab::Index => self.index_tab(),
ColorPickerTab::TrueColor => self.true_color_tab(),
ColorPickerTab::Books => self.books_tab(),
};
// The interface picker shrinks to the editor; the CAD panel keeps one
// height across its tabs, with the info line at the bottom.
let mut body = column![body].spacing(8);
if !editor_only {
body = body.push(Space::new().height(Length::Fill));
// Document and Color books fill the height themselves.
if matches!(self.tab, ColorPickerTab::Index | ColorPickerTab::TrueColor) {
body = body.push(Space::new().height(Length::Fill));
}
let fill =
math::color_rgb(self.pending).map_or(Color::from_rgb8(128, 128, 128), rgb_color);
let info = match (&self.status, &self.pending_name) {
(Some(status), _) => status.clone(),
(None, Some(name)) => {
let (r, g, b) = math::color_rgb(self.pending).unwrap_or_default();
format!("{name} · RGB {r}, {g}, {b}")
}
(None, None) => math::describe(self.pending),
};
let error = self.status.is_some();
body = body
.push(
container(Space::new())
@@ -196,9 +394,11 @@ impl ColorPicker {
.width(12)
.height(12)
.style(move |_theme: &Theme| swatch_style(fill)),
text(math::describe(self.pending))
.size(11)
.font(iced::Font::MONOSPACE),
text(info).size(11).font(iced::Font::MONOSPACE).style(
move |theme: &Theme| text::Style {
color: error.then(|| theme.palette().danger.base.color),
}
),
]
.spacing(8)
.align_y(iced::Center),
@@ -247,8 +447,10 @@ impl ColorPicker {
};
container(
row![
tab(t!("Document").into_owned(), ColorPickerTab::Document),
tab(t!("Index Color").into_owned(), ColorPickerTab::Index),
tab(t!("True Color").into_owned(), ColorPickerTab::TrueColor),
tab(t!("Color Books").into_owned(), ColorPickerTab::Books),
]
.spacing(2),
)
@@ -261,11 +463,7 @@ impl ColorPicker {
/// under the middle of the tab bar. Clicking Current reverts.
fn preview_row(&self) -> Element<'_, Message> {
let half = |label: String, value: String, fill: Color| {
let ink = if ink_is_dark(fill) {
Color::from_rgb8(0x11, 0x12, 0x14)
} else {
Color::WHITE
};
let ink = ink(fill);
column![
text(label).size(11).color(ink.scale_alpha(0.85)),
Space::new().height(Length::Fill),
@@ -277,10 +475,18 @@ impl ColorPicker {
};
let (current_bg, _) = acad_color_display(self.current);
let (new_bg, _) = acad_color_display(self.pending);
// A colour-book colour shows its own name, without the book.
let name = |color: AcadColor, identity: &Option<String>| match identity {
Some(identity) => identity
.split_once('$')
.map_or(identity.as_str(), |(_, name)| name)
.to_string(),
None => math::short_name(color),
};
let current_value = if self.varies {
"*VARIES*".to_string()
} else {
math::short_name(self.current)
name(self.current, &self.current_name)
};
let current = button(half(t!("Current").into_owned(), current_value, current_bg))
.on_press(picker_msg(ColorPickerMsg::Revert))
@@ -301,7 +507,7 @@ impl ColorPicker {
.style(|_theme: &Theme| filled(Color::from_rgba(0.0, 0.0, 0.0, 0.35)));
let new = container(half(
t!("New").into_owned(),
math::short_name(self.pending),
name(self.pending, &self.pending_name),
new_bg,
))
.style(move |_theme: &Theme| container::Style {
@@ -364,29 +570,272 @@ impl ColorPicker {
button(Space::new().width(width).height(height))
.on_press(picker_msg(ColorPickerMsg::Pick(AcadColor::Index(index))))
.padding(0)
.style(move |theme: &Theme, status| {
let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed);
button::Style {
background: Some(fill.into()),
.style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected))
.into()
}
fn document_tab(&self) -> Element<'_, Message> {
let header = row![
text(t!("Colors in this drawing")).size(11),
text(format!("· {}", self.doc.len()))
.size(11)
.style(muted_text),
Space::new().width(Length::Fill),
text(t!("Sort")).size(11).style(muted_text),
pick_list(Some(self.doc_sort), &DocSort::ALL[..], DocSort::to_string)
.on_select(|by| picker_msg(ColorPickerMsg::DocSort(by)))
.text_size(12)
.padding([3, 6]),
]
.spacing(6)
.align_y(iced::Center);
if self.doc.is_empty() {
return column![
header,
empty_note(t!("No colors in this drawing yet").into_owned())
]
.spacing(10)
.into();
}
let grid = self.doc.chunks(DOC_COLUMNS).enumerate().fold(
column![].spacing(4),
|grid, (r, chunk)| {
let line = chunk
.iter()
.enumerate()
.fold(row![].spacing(4), |line, (c, entry)| {
line.push(self.doc_swatch(r * DOC_COLUMNS + c, entry))
});
let line = (chunk.len()..DOC_COLUMNS)
.fold(line, |line, _| line.push(Space::new().width(Length::Fill)));
grid.push(line)
},
);
column![
header,
scrollable(grid).height(Length::Fill).spacing(4).direction(
scrollable::Direction::Vertical(
scrollable::Scrollbar::new().width(4).scroller_width(4)
)
),
]
.spacing(10)
.into()
}
fn doc_swatch<'a>(&self, i: usize, entry: &'a DocColor) -> Element<'a, Message> {
let fill = math::color_rgb(entry.color).map_or(Color::from_rgb8(128, 128, 128), rgb_color);
let selected = self.pending == entry.color && self.pending_name == entry.name;
button(
container(
text(entry.tag())
.size(10)
.font(iced::Font::MONOSPACE)
.color(ink(fill)),
)
.align_y(iced::Bottom)
.padding([1, 4])
.height(Length::Fill),
)
.on_press(picker_msg(ColorPickerMsg::PickDocument(i)))
.padding(0)
.width(Length::Fill)
.height(30)
.style(move |theme: &Theme, status| swatch_button_style(theme, status, fill, selected))
.into()
}
fn books_tab(&self) -> Element<'_, Message> {
let load_button = |content: Element<'static, Message>| {
button(content)
.on_press(picker_msg(ColorPickerMsg::LoadBook))
.style(button::secondary)
};
let Some(book) = self.books.get(self.book) else {
return container(
column![
text(t!("No color books loaded")).size(12).style(muted_text),
load_button(text(t!("Load color book (.acb)")).size(12).into())
.padding([6, 14]),
]
.spacing(10)
.align_x(iced::Center),
)
.center(Length::Fill)
.into();
};
let label = |label: std::borrow::Cow<'static, str>| text(label).size(11).style(muted_text);
let books = Arc::clone(&self.books);
let top = row![
column![
label(t!("Color book")),
pick_list(
Some(self.book),
(0..self.books.len()).collect::<Vec<_>>(),
move |&i| books[i].name.clone(),
)
.on_select(|i| picker_msg(ColorPickerMsg::Book(i)))
.text_size(12)
.padding([4, 6])
.width(Length::Fill),
]
.spacing(4)
.width(Length::Fill),
column![
label(t!("Find")),
text_input("", &self.book_query)
.on_input(|query| picker_msg(ColorPickerMsg::BookQuery(query)))
.size(12)
.padding([5, 8])
.font(iced::Font::MONOSPACE)
.width(116),
]
.spacing(4),
load_button(
svg(crate::ui::icons::themed_handle(
crate::ui::icons::FOLDER_OPEN
))
.width(15)
.height(15)
.into()
)
.padding([6, 7]),
]
.spacing(8)
.align_y(iced::Bottom);
// The page's colours, or the first matches while searching.
let selected = self.book_selection();
let query = self.book_query.trim().to_lowercase();
let mut bars = column![].spacing(2).height(Length::Fill);
let mut shown = 0;
if query.is_empty() {
let (start, colors) = book.page(self.book_page);
for (i, color) in colors.iter().enumerate() {
bars = bars.push(book_bar(start + i, color, selected));
shown += 1;
}
} else {
for (i, color) in book
.colors
.iter()
.enumerate()
.filter(|(_, color)| color.name.to_lowercase().contains(&query))
.take(PAGE_BARS)
{
bars = bars.push(book_bar(i, color, selected));
shown += 1;
}
}
let bars: Element<'_, Message> = if shown == 0 {
empty_note(t!("No colors match").into_owned())
} else {
mouse_area(bars)
.on_scroll(|delta| {
let (iced::mouse::ScrollDelta::Lines { y, .. }
| iced::mouse::ScrollDelta::Pixels { y, .. }) = delta;
picker_msg(ColorPickerMsg::BookScroll(y))
})
.into()
};
// Every page as one band of its middle colour; the shown page framed.
let strip = (0..book.page_count()).fold(column![], |strip, page| {
let (_, colors) = book.page(page);
let fill = rgb_color(colors[colors.len() / 2].rgb);
let shown = query.is_empty() && page == self.book_page;
strip.push(
button(Space::new())
.on_press(picker_msg(ColorPickerMsg::BookPage(page)))
.padding(0)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme: &Theme, _status| button::Style {
background: Some(fill.into()),
border: Border {
color: Color::WHITE,
width: if shown { 2.0 } else { 0.0 },
radius: 2.0.into(),
},
..Default::default()
}),
)
});
let arrow = |path: &'static str, page: usize| {
button(
svg(svg::Handle::from_memory(
format!(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7 4'>\
<path d='{path}' fill='black'/></svg>"
)
.into_bytes(),
))
.width(9)
.height(5)
.style(|theme: &Theme, _status| svg::Style {
color: Some(theme.palette().background.base.text),
}),
)
.on_press(picker_msg(ColorPickerMsg::BookPage(page)))
.width(Length::Fill)
.height(18)
.padding([6, 8])
.style(button::secondary)
};
let strip = column![
arrow("M0 4L3.5 0L7 4z", self.book_page.saturating_sub(1)),
container(strip)
.height(Length::Fill)
.padding(1)
.style(|theme: &Theme| container::Style {
border: Border {
color: if selected || hovered {
theme.palette().primary.base.color
} else {
Color::from_rgba(0.0, 0.0, 0.0, 0.35)
},
width: if selected {
2.5
} else if hovered {
1.5
} else {
1.0
},
radius: 2.0.into(),
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
})
.into()
}),
arrow("M0 0L7 0L3.5 4z", self.book_page + 1),
]
.spacing(3)
.width(26);
// RGB equivalent of the selected colour.
let chosen = selected.map(|i| &book.colors[i]);
let channel = |name: std::borrow::Cow<'static, str>, value: Option<u8>| {
row![
text(name).size(12).style(muted_text),
Space::new().width(Length::Fill),
text(value.map_or("—".to_string(), |v| v.to_string()))
.size(12)
.font(iced::Font::MONOSPACE),
]
};
let equivalent = column![
text(t!("RGB equivalent")).size(11),
channel(t!("Red"), chosen.map(|c| c.rgb.0)),
channel(t!("Green"), chosen.map(|c| c.rgb.1)),
channel(t!("Blue"), chosen.map(|c| c.rgb.2)),
container(Space::new())
.width(Length::Fill)
.height(1)
.style(|theme: &Theme| filled(theme.palette().background.weak.color)),
text(format!("{} / {}", self.book_page + 1, book.page_count()))
.size(11)
.style(muted_text),
text(chosen.map_or(String::new(), |c| c.name.clone())).size(12),
]
.spacing(6)
.width(104);
column![
top,
row![bars, strip, equivalent]
.spacing(10)
.height(Length::Fill)
]
.spacing(12)
.into()
}
fn true_color_tab(&self) -> Element<'_, Message> {
@@ -494,6 +943,21 @@ fn step_index(index: u8, direction: ArrowKey) -> Option<u8> {
}
}
/// Arrow keys over a list laid out `columns` wide. Nothing selected yet
/// starts at the first entry.
fn step_list(at: Option<usize>, len: usize, columns: usize, direction: ArrowKey) -> Option<usize> {
let Some(at) = at else {
return (len > 0).then_some(0);
};
let next = match direction {
ArrowKey::Left => at.checked_sub(1),
ArrowKey::Right => Some(at + 1),
ArrowKey::Up => at.checked_sub(columns),
ArrowKey::Down => Some(at + columns),
};
next.filter(|&next| next < len)
}
/// Move keyboard focus to the next (or previous) field inside the panel.
pub fn focus_step<T>(forward: bool) -> iced::Task<T> {
use iced::advanced::widget::operation::{focusable, scope};
@@ -514,6 +978,77 @@ fn ink_is_dark(color: Color) -> bool {
math::ink_is_dark(r, g, b)
}
/// Text that reads on top of `fill`.
fn ink(fill: Color) -> Color {
if ink_is_dark(fill) {
Color::from_rgb8(0x11, 0x12, 0x14)
} else {
Color::WHITE
}
}
/// One colour of a colour book: a full-width bar with its name.
fn book_bar(
i: usize,
color: &super::book::BookColor,
selected: Option<usize>,
) -> Element<'_, Message> {
let fill = rgb_color(color.rgb);
button(
container(
text(&color.name)
.size(12)
.font(iced::Font::MONOSPACE)
.color(ink(fill)),
)
.center(Length::Fill),
)
.on_press(picker_msg(ColorPickerMsg::PickBook(i)))
.padding(0)
.width(Length::Fill)
.height(Length::Fill)
.style(move |theme: &Theme, status| {
swatch_button_style(theme, status, fill, selected == Some(i))
})
.into()
}
/// A short gray note in the middle of the free space.
fn empty_note<'a>(note: String) -> Element<'a, Message> {
container(text(note).size(12).style(muted_text))
.center(Length::Fill)
.into()
}
/// A clickable colour: ringed in the accent colour when selected or hovered.
fn swatch_button_style(
theme: &Theme,
status: button::Status,
fill: Color,
selected: bool,
) -> button::Style {
let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed);
button::Style {
background: Some(fill.into()),
border: Border {
color: if selected || hovered {
theme.palette().primary.base.color
} else {
Color::from_rgba(0.0, 0.0, 0.0, 0.35)
},
width: if selected {
2.5
} else if hovered {
1.5
} else {
1.0
},
radius: 2.0.into(),
},
..Default::default()
}
}
fn filled(color: Color) -> container::Style {
container::Style {
background: Some(color.into()),
@@ -641,7 +1176,7 @@ mod tests {
p.update(ColorPickerMsg::Pick(AcadColor::Index(5)));
assert_eq!(
p.update(ColorPickerMsg::Ok),
Some(Outcome::Commit(AcadColor::Index(5)))
Some(Outcome::Commit(AcadColor::Index(5), None))
);
assert_eq!(p.update(ColorPickerMsg::Cancel), Some(Outcome::Cancel));
}
@@ -652,11 +1187,14 @@ mod tests {
edit(&mut p, EditorMsg::HexInput("#3bf".into()));
assert_eq!(
p.update(ColorPickerMsg::Ok),
Some(Outcome::Commit(AcadColor::Rgb {
r: 0x33,
g: 0xBB,
b: 0xFF
}))
Some(Outcome::Commit(
AcadColor::Rgb {
r: 0x33,
g: 0xBB,
b: 0xFF
},
None
))
);
}
@@ -724,6 +1262,89 @@ mod tests {
);
}
fn ral() -> Arc<Vec<ColorBook>> {
let entry = |name: &str, (r, g, b): (u8, u8, u8)| {
format!(
"<colorEntry><colorName>{name}</colorName><RGB8><red>{r}</red>\
<green>{g}</green><blue>{b}</blue></RGB8></colorEntry>"
)
};
let text = format!(
"<colorBook><bookName>RAL CLASSIC</bookName><colorPage>{}{}</colorPage>\
<colorPage>{}</colorPage></colorBook>",
entry("RAL 1000", (190, 189, 127)),
entry("RAL 3020", (204, 6, 5)),
entry("RAL 5015", (34, 113, 179)),
);
Arc::new(vec![super::super::book::parse(&text).unwrap()])
}
#[test]
fn book_colours_keep_their_name_until_edited() {
let mut p = picker(AcadColor::Index(30)).with_drawing(Vec::new(), &[], ral(), None);
p.update(ColorPickerMsg::PickBook(2));
assert_eq!(p.book_page, 1, "turns to the colour's page");
assert_eq!(
p.update(ColorPickerMsg::Ok),
Some(Outcome::Commit(
AcadColor::Rgb {
r: 34,
g: 113,
b: 179
},
Some("RAL CLASSIC$RAL 5015".into())
))
);
p.update(ColorPickerMsg::Arrow(ArrowKey::Up));
assert_eq!(p.pending_name.as_deref(), Some("RAL CLASSIC$RAL 3020"));
edit(&mut p, EditorMsg::Hue(120.0));
assert_eq!(
p.pending_name, None,
"an edited colour is no longer the book's"
);
}
#[test]
fn opens_a_book_colour_on_its_page() {
let p = picker(AcadColor::Rgb {
r: 34,
g: 113,
b: 179,
})
.with_drawing(Vec::new(), &[], ral(), Some("RAL CLASSIC$RAL 5015".into()));
assert_eq!(p.tab(), ColorPickerTab::Books);
assert_eq!(p.book_selection(), Some(2));
assert_eq!(p.book_page, 1);
}
#[test]
fn document_colours_pick_and_walk() {
// A new drawing's layer 0 is white.
let doc = document::collect(&codec::CadDocument::new());
assert!(doc.iter().any(|c| c.color == AcadColor::Index(7)));
let mut p = picker(AcadColor::Index(30)).with_drawing(doc, &[], Arc::default(), None);
p.update(ColorPickerMsg::Tab(ColorPickerTab::Document));
p.update(ColorPickerMsg::Arrow(ArrowKey::Right));
assert_eq!(
p.pending(),
p.doc[0].color,
"the first key press starts at the top"
);
assert_eq!(step_list(None, 3, 8, ArrowKey::Down), Some(0));
assert_eq!(step_list(Some(1), 20, 8, ArrowKey::Down), Some(9));
assert_eq!(step_list(Some(1), 5, 8, ArrowKey::Down), None);
assert_eq!(step_list(Some(0), 5, 8, ArrowKey::Left), None);
}
#[test]
fn loading_a_book_is_left_to_the_app() {
let mut p = picker(AcadColor::Index(30));
assert_eq!(p.update(ColorPickerMsg::LoadBook), Some(Outcome::LoadBook));
p.show_book(ral(), 0);
assert_eq!(p.tab(), ColorPickerTab::Books);
}
// ── Headless UI tests ───────────────────────────────────────────────
//
// These run the real widgets through iced's simulator. Setting
@@ -809,7 +1430,31 @@ mod tests {
ColorPickerTab,
EditorMode,
bool,
); 7] = [
); 10] = [
(
"document",
ColorPickTarget::Properties,
AcadColor::Index(7),
ColorPickerTab::Document,
EditorMode::Hsb,
false,
),
(
"books",
ColorPickTarget::Properties,
AcadColor::Index(30),
ColorPickerTab::Books,
EditorMode::Hsb,
false,
),
(
"no-books",
ColorPickTarget::Properties,
AcadColor::Index(30),
ColorPickerTab::Books,
EditorMode::Hsb,
false,
),
(
"index",
ColorPickTarget::Properties,
@@ -876,8 +1521,21 @@ mod tests {
crate::ui::style::fusion_theme::fusion_white(),
] {
for (name, target, color, tab, mode, varies) in views.clone() {
let mut p = ColorPicker::new(target, color, varies);
let books = if name == "no-books" {
Arc::default()
} else {
ral()
};
let mut p = ColorPicker::new(target, color, varies).with_drawing(
document::collect(&codec::CadDocument::new()),
&[],
books,
None,
);
p.update(ColorPickerMsg::Tab(tab));
if name == "books" {
p.update(ColorPickerMsg::PickBook(1));
}
edit(&mut p, EditorMsg::Mode(mode));
let mut ui = simulator(&p);
let snapshot = ui.snapshot(&theme).expect("snapshot");