forked from 1b-agent/opencadstudio-upstream
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:
35 files changed
+1684
-129
No files matched your search
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
};
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user