Merge pull request #1795 from MarcelP102/Select-Color-Redesign

[UX] Select Color redesign and feature parity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-15 00:21:10 +03:00
commit 843cbcd607
56 files changed
+4716 -606

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
//! 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)
}
}
/// Add `book`, replacing one of the same name; returns where it now is.
pub fn add_or_replace(books: &mut Vec<ColorBook>, book: ColorBook) -> usize {
match books.iter().position(|b| b.name == book.name) {
Some(index) => {
books[index] = book;
index
}
None => {
books.push(book);
books.len() - 1
}
}
}
/// The colour book that ships with the program; its licence is in the
/// file's header comment.
pub fn builtin() -> impl Iterator<Item = ColorBook> {
parse(include_str!("../../../assets/color_books/ral-classic.acb")).into_iter()
}
/// Where colour books are kept: `color_books` in the config folder. The
/// web has no folders.
#[cfg(not(target_arch = "wasm32"))]
pub fn folder() -> Option<std::path::PathBuf> {
crate::config::config_dir().map(|dir| dir.join("color_books"))
}
/// The built-in book plus every `.acb` file in the colour book folder, in
/// file-name order. A book replaces an earlier one of the same name.
pub fn read_all() -> Vec<ColorBook> {
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut books: Vec<_> = builtin().collect();
#[cfg(not(target_arch = "wasm32"))]
if let Some(dir) = folder() {
let mut files: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("acb"))
})
.collect();
files.sort();
for book in files
.into_iter()
.filter_map(|path| std::fs::read_to_string(path).ok())
.filter_map(|text| parse(&text).ok())
{
add_or_replace(&mut books, book);
}
}
books
}
/// Ask for an `.acb` file and read it. On desktop the file is copied into
/// the colour book folder, so it stays when the original moves; the web
/// keeps the book for the session only.
pub async fn load_from_dialog() -> Result<ColorBook, String> {
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 bytes = file.read().await;
let book = parse(&String::from_utf8_lossy(&bytes))?;
#[cfg(not(target_arch = "wasm32"))]
if let Some(folder) = folder() {
std::fs::create_dir_all(&folder)
.and_then(|()| std::fs::write(folder.join(file.file_name()), &bytes))
.map_err(|e| e.to_string())?;
}
Ok(book)
}
/// 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());
}
}
+247
View File
@@ -0,0 +1,247 @@
//! 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 name in the list view: the colour-book colour's own name (without
/// the book), `ACI n` or `#RRGGBB`.
pub fn label(&self) -> String {
match &self.name {
Some(name) => name
.split_once('$')
.map_or(name.as_str(), |(_, name)| name)
.to_string(),
None => math::short_name(self.color),
}
}
}
/// Swatches or a list of named bars.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DocView {
#[default]
Grid,
List,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DocSort {
#[default]
Recent,
Usage,
Hue,
Number,
NameAsc,
NameDesc,
}
impl DocSort {
pub const ALL: [DocSort; 6] = [
DocSort::Recent,
DocSort::Usage,
DocSort::Hue,
DocSort::Number,
DocSort::NameAsc,
DocSort::NameDesc,
];
}
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"),
DocSort::NameAsc => crate::t!("Name A–Z"),
DocSort::NameDesc => crate::t!("Name Z–A"),
})
}
}
/// 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,
}
}
/// A layer's colour-book identity, `BOOK$NAME`. Layers keep the book and
/// the colour name apart; objects store them joined like this.
pub fn layer_identity(layer: &codec::tables::Layer) -> Option<String> {
match (&layer.book_name, &layer.color_name) {
(Some(book), Some(name)) => Some(format!("{book}${name}")),
(None, Some(name)) => Some(name.clone()),
_ => 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> {
let layer_names: Vec<Option<String>> = document.layers.iter().map(layer_identity).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]) {
match by {
// The rank is looked up once per colour, not on every comparison.
DocSort::Recent => colors.sort_by_cached_key(|c| {
let rank = recent
.iter()
.position(|&r| r == c.color)
.unwrap_or(usize::MAX);
(rank, std::cmp::Reverse(c.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)))
}),
DocSort::NameAsc => colors.sort_by_cached_key(|c| c.label().to_lowercase()),
DocSort::NameDesc => {
colors.sort_by_cached_key(|c| std::cmp::Reverse(c.label().to_lowercase()))
}
}
}
#[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].label(), "RAL 5015");
assert_eq!(colors[0].label(), "ACI 1");
assert_eq!(colors[1].label(), "#39BFFC");
}
#[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));
}
}
+506
View File
@@ -0,0 +1,506 @@
//! The colour editor: RGB, HSB or CMYK sliders above a saturation/brightness
//! field with a hue strip, plus a hex box. Shared by the CAD Select Color
//! panel and the interface colour picker.
use super::math::{self, Hsb};
use super::widgets::ColorArea;
use super::ColorPickerMsg;
use crate::app::Message;
use crate::t;
use iced::widget::{
button, column, container, row, slider, stack, text, text_input, vertical_slider, Space,
};
use iced::{Background, Border, Color, Element, Gradient, Length, Radians, Theme};
const ROW_HEIGHT: f32 = 26.0;
const TRACK: f32 = 18.0;
const STRIP_WIDTH: f32 = 22.0;
const ROW_GAP: f32 = 6.0;
/// The field's height in the interface picker, which has no fixed height.
const FIELD_HEIGHT: f32 = 96.0;
const MODE_WIDTH: f32 = 48.0;
const MODE_HEIGHT: f32 = 24.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EditorMode {
#[default]
Rgb,
Hsb,
Cmyk,
}
#[derive(Debug, Clone)]
pub enum EditorMsg {
Mode(EditorMode),
Hue(f32),
/// Saturation in percent.
Saturation(f32),
/// Brightness in percent.
Brightness(f32),
/// Saturation and brightness from the field, both 0–1.
Field(f32, f32),
Red(u8),
Green(u8),
Blue(u8),
/// One CMYK channel (0 cyan … 3 black), in percent.
Cmyk(usize, f32),
HexInput(String),
HexSubmit,
}
pub struct ColorEditor {
mode: EditorMode,
/// Kept beside `rgb` so hue and saturation survive grays and black.
hsb: Hsb,
/// Kept too, so black keeps its inks and rounding doesn't move them.
cmyk: [f32; 4],
rgb: (u8, u8, u8),
hex_draft: Option<String>,
}
impl ColorEditor {
pub fn new(rgb: (u8, u8, u8)) -> Self {
let mut editor = Self {
mode: EditorMode::default(),
hsb: Hsb {
h: 0.0,
s: 0.0,
b: 1.0,
},
cmyk: [0.0; 4],
rgb,
hex_draft: None,
};
editor.set_rgb(rgb);
editor
}
pub fn rgb(&self) -> (u8, u8, u8) {
self.rgb
}
pub fn set_rgb(&mut self, (r, g, b): (u8, u8, u8)) {
let next = math::rgb_to_hsb(r, g, b);
// A gray has no hue and black no saturation; keep the previous ones
// so the sliders and field don't jump.
let gray = next.s == 0.0 || next.b == 0.0;
self.hsb = Hsb {
h: if gray { self.hsb.h } else { next.h },
s: if next.b == 0.0 { self.hsb.s } else { next.s },
b: next.b,
};
self.cmyk = math::rgb_to_cmyk(r, g, b);
self.rgb = (r, g, b);
self.hex_draft = None;
}
fn set_hsb(&mut self, hsb: Hsb) {
self.hsb = Hsb {
h: hsb.h.clamp(0.0, 360.0),
s: hsb.s.clamp(0.0, 1.0),
b: hsb.b.clamp(0.0, 1.0),
};
self.rgb = self.hsb.to_rgb();
self.cmyk = math::rgb_to_cmyk(self.rgb.0, self.rgb.1, self.rgb.2);
self.hex_draft = None;
}
fn set_cmyk(&mut self, channel: usize, value: f32) {
let mut cmyk = self.cmyk;
cmyk[channel.min(3)] = value.clamp(0.0, 100.0);
self.set_rgb(math::cmyk_to_rgb(cmyk));
self.cmyk = cmyk;
}
/// Apply a message; returns whether the colour changed.
pub fn update(&mut self, msg: EditorMsg) -> bool {
let before = self.rgb;
let (r, g, b) = self.rgb;
match msg {
EditorMsg::Mode(mode) => self.mode = mode,
EditorMsg::Hue(h) => self.set_hsb(Hsb { h, ..self.hsb }),
EditorMsg::Saturation(s) => self.set_hsb(Hsb {
s: s / 100.0,
..self.hsb
}),
EditorMsg::Brightness(v) => self.set_hsb(Hsb {
b: v / 100.0,
..self.hsb
}),
EditorMsg::Field(s, b) => self.set_hsb(Hsb { s, b, ..self.hsb }),
EditorMsg::Red(v) => self.set_rgb((v, g, b)),
EditorMsg::Green(v) => self.set_rgb((r, v, b)),
EditorMsg::Blue(v) => self.set_rgb((r, g, v)),
EditorMsg::Cmyk(channel, v) => self.set_cmyk(channel, v),
EditorMsg::HexInput(value) => {
// Six digits are unambiguous, so apply them while typing; the
// short #RGB form waits for Enter.
let complete = value.trim().trim_start_matches('#').len() == 6;
if let Some(rgb) = math::parse_hex(&value).filter(|_| complete) {
self.set_rgb(rgb);
}
self.hex_draft = Some(value);
}
EditorMsg::HexSubmit => {
if let Some(rgb) = self.hex_draft.take().and_then(|d| math::parse_hex(&d)) {
self.set_rgb(rgb);
}
}
}
self.rgb != before
}
/// `mode_end` sits at the far right of the mode buttons (the pipette);
/// `hex_end` at the far right of the hex row (the nearest index colour).
/// With `fill` the field takes all the height the sliders leave, so the
/// hex row stays at the bottom and CMYK's fourth slider only makes the
/// field a little lower.
pub fn view<'a>(
&'a self,
mode_end: Option<Element<'a, Message>>,
hex_end: Option<Element<'a, Message>>,
fill: bool,
) -> Element<'a, Message> {
let height = if fill {
Length::Fill
} else {
Length::Fixed(FIELD_HEIGHT)
};
column![
self.mode_row(mode_end),
self.slider_rows(),
self.field(height),
self.hex_row(hex_end),
]
.spacing(12)
.into()
}
/// RGB, HSB and CMYK, with `end` at the far right.
fn mode_row<'a>(&self, end: Option<Element<'a, Message>>) -> Element<'a, Message> {
let mode_button = |label: &'static str, mode: EditorMode| {
let active = self.mode == mode;
button(container(text(label).size(13)).center(Length::Fill))
.on_press(msg(EditorMsg::Mode(mode)))
.width(MODE_WIDTH)
.height(MODE_HEIGHT)
.padding(0)
.style(move |theme: &Theme, status| {
super::picker::toggle_style(theme, active, status)
})
};
let mut modes = row![
mode_button("RGB", EditorMode::Rgb),
mode_button("HSB", EditorMode::Hsb),
mode_button("CMYK", EditorMode::Cmyk),
Space::new().width(Length::Fill),
]
.spacing(6);
if let Some(end) = end {
modes = modes.push(end);
}
modes.into()
}
/// Gradient sliders: red, green and blue; hue, saturation and
/// brightness; or cyan, magenta, yellow and black.
fn slider_rows<'a>(&self) -> Element<'a, Message> {
let Hsb { h, s, b } = self.hsb;
let (r, g, bl) = self.rgb;
let rows = if self.mode == EditorMode::Cmyk {
let ink = |i: usize, letter| {
let at = |v| {
let mut cmyk = self.cmyk;
cmyk[i] = v;
rgb_color(math::cmyk_to_rgb(cmyk))
};
channel(
letter,
self.cmyk[i],
100.0,
Some('%'),
vec![(0.0, at(0.0)), (1.0, at(100.0))],
move |v| EditorMsg::Cmyk(i, v),
)
};
column![ink(0, "C"), ink(1, "M"), ink(2, "Y"), ink(3, "K")]
} else if self.mode == EditorMode::Rgb {
let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| {
vec![(0.0, rgb_color(lo)), (1.0, rgb_color(hi))]
};
let byte = |f: fn(u8) -> EditorMsg| move |v: f32| f(v.round() as u8);
column![
channel(
"R",
f32::from(r),
255.0,
None,
ends((0, g, bl), (255, g, bl)),
byte(EditorMsg::Red)
),
channel(
"G",
f32::from(g),
255.0,
None,
ends((r, 0, bl), (r, 255, bl)),
byte(EditorMsg::Green)
),
channel(
"B",
f32::from(bl),
255.0,
None,
ends((r, g, 0), (r, g, 255)),
byte(EditorMsg::Blue)
),
]
} else {
column![
channel("H", h, 360.0, Some('°'), hue_stops(), EditorMsg::Hue),
channel(
"S",
s * 100.0,
100.0,
Some('%'),
vec![(0.0, hsb_color(h, 0.0, b)), (1.0, hsb_color(h, 1.0, b))],
EditorMsg::Saturation,
),
channel(
"B",
b * 100.0,
100.0,
Some('%'),
vec![(0.0, Color::BLACK), (1.0, hsb_color(h, s, 1.0))],
EditorMsg::Brightness,
),
]
};
rows.spacing(ROW_GAP).into()
}
/// The saturation/brightness field with its hue strip.
fn field<'a>(&self, height: Length) -> Element<'a, Message> {
let Hsb { h, s, b } = self.hsb;
let area = ColorArea::new(hsb_color(h, 1.0, 1.0), rgb_color(self.rgb), s, b, |s, b| {
msg(EditorMsg::Field(s, b))
})
.height(height);
// A vertical slider grows upwards; the strip runs red at the top.
let strip = stack![
container(Space::new())
.width(STRIP_WIDTH)
.height(height)
.style(|_theme: &Theme| gradient_box(std::f32::consts::PI, &hue_stops())),
vertical_slider(0.0..=360.0, 360.0 - h, |v| msg(EditorMsg::Hue(360.0 - v)))
.width(STRIP_WIDTH)
.height(height)
.style(handle_style),
];
row![area, strip].spacing(12).into()
}
/// The hex value with `end` (the nearest index colour) at the far right.
fn hex_row<'a>(&self, end: Option<Element<'a, Message>>) -> Element<'a, Message> {
let (r, g, b) = self.rgb;
let hex = self
.hex_draft
.clone()
.unwrap_or_else(|| math::hex_string(r, g, b));
let mut hex_row = row![
text(t!("Hex")).size(11).style(super::picker::muted_text),
text_input("", &hex)
.on_input(|value| msg(EditorMsg::HexInput(value)))
.on_submit(msg(EditorMsg::HexSubmit))
.size(12)
.font(iced::Font::MONOSPACE)
.padding([5, 6])
.width(Length::Fixed(74.0)),
Space::new().width(Length::Fill),
]
.spacing(8)
.align_y(iced::Center);
if let Some(end) = end {
hex_row = hex_row.push(end);
}
hex_row.into()
}
}
fn msg(editor: EditorMsg) -> Message {
Message::ColorPicker(ColorPickerMsg::Editor(editor))
}
/// One labelled channel: a gradient slider and its number box.
fn channel<'a>(
letter: &'static str,
value: f32,
max: f32,
unit: Option<char>,
stops: Vec<(f32, Color)>,
on_change: impl Fn(f32) -> EditorMsg + Copy + 'static,
) -> Element<'a, Message> {
// iced's slider splits its own rail at the handle, which would squeeze a
// gradient; draw the gradient underneath and keep the rail transparent.
let track = container(
container(Space::new())
.width(Length::Fill)
.height(TRACK)
.style(move |_theme: &Theme| gradient_box(std::f32::consts::FRAC_PI_2, &stops)),
)
.height(ROW_HEIGHT)
.center_y(ROW_HEIGHT);
row![
container(text(letter).size(12).font(iced::Font::MONOSPACE))
.width(12)
.align_x(iced::Center),
stack![
track,
slider(0.0..=max, value, move |v| msg(on_change(v)))
.height(ROW_HEIGHT)
.shift_step(10.0)
.style(handle_style),
],
number_box(value, max, unit, on_change),
]
.spacing(8)
.align_y(iced::Center)
.into()
}
/// A compact spin box with the unit drawn inside, after the digits.
fn number_box(
value: f32,
max: f32,
unit: Option<char>,
on_change: impl Fn(f32) -> EditorMsg + Copy + 'static,
) -> Element<'static, Message> {
let input =
iced_aw::widget::NumberInput::new(&(value.round() as u16), 0..=max as u16, move |v| {
msg(on_change(f32::from(v)))
})
.set_size(12.0)
.font(iced::Font::MONOSPACE)
.width(Length::Fixed(54.0))
// Padding under 5 would make NumberInput swap its arrows for + / −;
// the extra right padding keeps the digits clear of the arrows.
.padding(iced::Padding {
top: 5.0,
right: 18.0,
bottom: 5.0,
left: 2.0,
})
.align_x(iced::alignment::Horizontal::Right)
.style(
|theme: &Theme, _status| iced_aw::style::number_input::Style {
button_background: None,
icon_color: theme.palette().background.base.text.scale_alpha(0.75),
},
);
match unit {
Some(code_point) => input
.icon(text_input::Icon {
font: iced::Font::MONOSPACE,
code_point,
size: Some(iced::Pixels(12.0)),
spacing: 1.0,
side: text_input::Side::Right,
})
.into(),
None => input.into(),
}
}
/// Transparent rail with a white bar handle, for both slider directions.
fn handle_style(_theme: &Theme, _status: slider::Status) -> slider::Style {
slider::Style {
rail: slider::Rail {
backgrounds: (Color::TRANSPARENT.into(), Color::TRANSPARENT.into()),
width: TRACK,
border: Border::default(),
},
handle: slider::Handle {
shape: slider::HandleShape::Rectangle {
width: 8,
border_radius: 3.0.into(),
},
background: Color::WHITE.into(),
border_width: 1.0,
border_color: Color::from_rgba(0.0, 0.0, 0.0, 0.4),
},
}
}
/// A rounded box filled with a linear gradient. Angles run clockwise from
/// "bottom to top": a quarter turn is left to right, a half turn top to bottom.
fn gradient_box(angle: f32, stops: &[(f32, Color)]) -> container::Style {
let linear = stops.iter().fold(
iced::gradient::Linear::new(Radians(angle)),
|linear, &(at, color)| linear.add_stop(at, color),
);
container::Style {
background: Some(Background::Gradient(Gradient::Linear(linear))),
border: Border {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.35),
width: 1.0,
radius: 5.0.into(),
},
..Default::default()
}
}
fn hue_stops() -> Vec<(f32, Color)> {
(0..=6)
.map(|step| {
let at = step as f32 / 6.0;
(at, hsb_color(at * 360.0, 1.0, 1.0))
})
.collect()
}
fn hsb_color(h: f32, s: f32, b: f32) -> Color {
rgb_color(math::hsb_to_rgb(h, s, b))
}
pub(super) fn rgb_color((r, g, b): (u8, u8, u8)) -> Color {
Color::from_rgb8(r, g, b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sliders_produce_colours() {
let mut e = ColorEditor::new((255, 0, 0));
assert!(e.update(EditorMsg::Hue(120.0)));
assert_eq!(e.rgb(), (0, 255, 0));
e.update(EditorMsg::Red(10));
assert_eq!(e.rgb(), (10, 255, 0));
assert!(!e.update(EditorMsg::Mode(EditorMode::Rgb)));
}
#[test]
fn hue_survives_a_trip_through_gray() {
let mut e = ColorEditor::new((0, 0, 255));
e.update(EditorMsg::Saturation(0.0));
e.update(EditorMsg::Saturation(100.0));
assert_eq!(e.rgb(), (0, 0, 255));
}
#[test]
fn hex_applies_six_digits_while_typing() {
let mut e = ColorEditor::new((0, 0, 0));
assert!(!e.update(EditorMsg::HexInput("#39BF".into())));
assert!(e.update(EditorMsg::HexInput("#39BFFC".into())));
assert_eq!(e.rgb(), (57, 191, 252));
}
#[test]
fn short_hex_waits_for_enter() {
let mut e = ColorEditor::new((0, 0, 0));
assert!(!e.update(EditorMsg::HexInput("#3bf".into())));
assert!(e.update(EditorMsg::HexSubmit));
assert_eq!(e.rgb(), (0x33, 0xBB, 0xFF));
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Pick a colour from anywhere inside the application window.
//!
//! While active, a transparent layer covers the window with a crosshair. The
//! first mouse move after the panel is hidden asks iced for a screenshot of
//! the window; a magnifier beside the cursor shows the pixels around it from
//! that screenshot, and a click picks the centre pixel.
use super::editor::rgb_color;
use super::{math, ColorPickerMsg};
use crate::app::Message;
use iced::widget::{column, container, mouse_area, pin, row, stack, text, Space};
use iced::window::Screenshot;
use iced::{Border, Color, Element, Length, Point, Theme};
/// Pixels shown across the magnifier (odd, so one sits in the centre).
const GRID: i32 = 11;
const CELL: f32 = 10.0;
/// Gap between the cursor and the magnifier.
const OFFSET: f32 = 20.0;
#[derive(Debug, Clone)]
pub enum EyedropperMsg {
Start,
Move(Point),
Shot(Screenshot),
Pick,
}
#[derive(Default)]
pub struct Eyedropper {
shot: Option<Screenshot>,
requested: bool,
cursor: Option<Point>,
}
impl Eyedropper {
/// Track the cursor. Returns true when the window screenshot should be
/// requested now — the panel is gone from the screen by then.
pub fn track(&mut self, cursor: Point) -> bool {
self.cursor = Some(cursor);
let request = self.shot.is_none() && !self.requested;
self.requested = true;
request
}
pub fn set_shot(&mut self, shot: Screenshot) {
self.shot = Some(shot);
}
/// The pixel under the cursor, once the screenshot has arrived.
pub fn picked(&self) -> Option<(u8, u8, u8)> {
sample(self.shot.as_ref()?, self.cursor?, 0, 0)
}
pub fn view(&self) -> Element<'_, Message> {
let eye = |m| Message::ColorPicker(ColorPickerMsg::Eyedropper(m));
let catcher = mouse_area(Space::new().width(Length::Fill).height(Length::Fill))
.on_move(move |p| eye(EyedropperMsg::Move(p)))
.on_press(eye(EyedropperMsg::Pick))
.interaction(iced::mouse::Interaction::Crosshair);
let (Some(shot), Some(cursor)) = (self.shot.as_ref(), self.cursor) else {
return catcher.into();
};
let half = GRID / 2;
let mut grid = column![];
for dy in -half..=half {
let mut line = row![];
for dx in -half..=half {
let fill = sample(shot, cursor, dx, dy).map_or(Color::TRANSPARENT, rgb_color);
let centre = dx == 0 && dy == 0;
line = line.push(container(Space::new()).width(CELL).height(CELL).style(
move |_theme: &Theme| container::Style {
background: Some(fill.into()),
border: Border {
color: if centre {
Color::WHITE
} else {
Color::TRANSPARENT
},
width: if centre { 1.5 } else { 0.0 },
radius: 0.0.into(),
},
..Default::default()
},
));
}
grid = grid.push(line);
}
let (r, g, b) = self.picked().unwrap_or((0, 0, 0));
let loupe = container(
column![
grid,
text(math::hex_string(r, g, b))
.size(11)
.font(iced::Font::MONOSPACE),
]
.spacing(4)
.align_x(iced::Center),
)
.padding(4)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(palette.background.base.color.into()),
text_color: Some(palette.background.base.text),
border: Border {
color: palette.background.strong.color,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
}
});
// Keep the magnifier on screen: flip it to the other side of the
// cursor near the right and bottom edges.
let size = GRID as f32 * CELL + 8.0;
let window_w = shot.size.width as f32 / shot.scale_factor;
let window_h = shot.size.height as f32 / shot.scale_factor;
// In a very small window neither side fits; stay inside the top-left.
let x = if cursor.x + OFFSET + size > window_w {
cursor.x - OFFSET - size
} else {
cursor.x + OFFSET
}
.max(0.0);
let y = if cursor.y + OFFSET + size + 20.0 > window_h {
cursor.y - OFFSET - size - 20.0
} else {
cursor.y + OFFSET
}
.max(0.0);
stack![catcher, pin(loupe).x(x).y(y)].into()
}
}
/// The screenshot pixel `dx`, `dy` physical pixels away from a logical point.
fn sample(shot: &Screenshot, at: Point, dx: i32, dy: i32) -> Option<(u8, u8, u8)> {
let x = (at.x * shot.scale_factor) as i64 + i64::from(dx);
let y = (at.y * shot.scale_factor) as i64 + i64::from(dy);
let (w, h) = (i64::from(shot.size.width), i64::from(shot.size.height));
if !(0..w).contains(&x) || !(0..h).contains(&y) {
return None;
}
let i = ((y * w + x) * 4) as usize;
let px = shot.rgba.get(i..i + 3)?;
Some((px[0], px[1], px[2]))
}
#[cfg(test)]
mod tests {
use super::*;
fn shot() -> Screenshot {
// 4×2 physical pixels at scale 2: pixel (x, y) has red = x, green = y.
let mut rgba = Vec::new();
for y in 0..2u8 {
for x in 0..4u8 {
rgba.extend([x, y, 9, 255]);
}
}
Screenshot::new(rgba, iced::Size::new(4, 2), 2.0)
}
#[test]
fn samples_physical_pixels_from_logical_points() {
let s = shot();
assert_eq!(sample(&s, Point::new(1.0, 0.5), 0, 0), Some((2, 1, 9)));
assert_eq!(sample(&s, Point::new(1.0, 0.5), 1, -1), Some((3, 0, 9)));
assert_eq!(sample(&s, Point::new(1.9, 0.0), 1, 0), None);
}
#[test]
fn asks_for_one_screenshot_and_picks_after_it_arrives() {
let mut e = Eyedropper::default();
assert!(e.track(Point::new(0.0, 0.0)));
assert!(!e.track(Point::new(1.0, 0.0)));
assert_eq!(e.picked(), None);
e.set_shot(shot());
assert_eq!(e.picked(), Some((2, 0, 9)));
}
}
+212
View File
@@ -0,0 +1,212 @@
//! Colour maths and formatting for the Select Color panel.
//!
//! Everything here is pure so it can be unit tested without a UI: HSB/RGB
//! conversion, hex formatting and parsing, nearest-index lookup and the
//! short names the panel shows.
use codec::types::Color as AcadColor;
/// Hue in degrees (0–360), saturation and brightness in 0–1.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Hsb {
pub h: f32,
pub s: f32,
pub b: f32,
}
impl Hsb {
pub fn to_rgb(self) -> (u8, u8, u8) {
hsb_to_rgb(self.h, self.s, self.b)
}
}
/// Convert hue (degrees), saturation and brightness (0–1) to 8-bit RGB.
pub fn hsb_to_rgb(h: f32, s: f32, b: f32) -> (u8, u8, u8) {
let h = h.rem_euclid(360.0);
let s = s.clamp(0.0, 1.0);
let b = b.clamp(0.0, 1.0);
let channel = |n: f32| {
let k = (n + h / 60.0) % 6.0;
let v = b - b * s * k.min(4.0 - k).clamp(0.0, 1.0);
(v * 255.0).round() as u8
};
(channel(5.0), channel(3.0), channel(1.0))
}
/// Convert 8-bit RGB to HSB. Grays have no hue; they report 0.
pub fn rgb_to_hsb(r: u8, g: u8, b: u8) -> Hsb {
let (r, g, b) = (
f32::from(r) / 255.0,
f32::from(g) / 255.0,
f32::from(b) / 255.0,
);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let mut h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max == g {
60.0 * ((b - r) / delta + 2.0)
} else {
60.0 * ((r - g) / delta + 4.0)
};
if h < 0.0 {
h += 360.0;
}
let s = if max == 0.0 { 0.0 } else { delta / max };
Hsb { h, s, b: max }
}
/// Cyan, magenta, yellow and black in percent, by the plain device-free
/// formula (no ICC profile): screen values, not press values.
pub fn rgb_to_cmyk(r: u8, g: u8, b: u8) -> [f32; 4] {
let max = r.max(g).max(b);
if max == 0 {
return [0.0, 0.0, 0.0, 100.0];
}
let max = f32::from(max);
let ink = |v: u8| (1.0 - f32::from(v) / max) * 100.0;
[ink(r), ink(g), ink(b), (1.0 - max / 255.0) * 100.0]
}
pub fn cmyk_to_rgb([c, m, y, k]: [f32; 4]) -> (u8, u8, u8) {
let white = 255.0 * (1.0 - k.clamp(0.0, 100.0) / 100.0);
let channel = |ink: f32| (white * (1.0 - ink.clamp(0.0, 100.0) / 100.0)).round() as u8;
(channel(c), channel(m), channel(y))
}
/// RGB of an ACI entry, or `None` outside 1–255.
pub fn aci_rgb(index: u8) -> Option<(u8, u8, u8)> {
codec::types::aci_table::aci_to_rgb(index)
}
/// The RGB a concrete colour displays as. Logical colours have none.
pub fn color_rgb(color: AcadColor) -> Option<(u8, u8, u8)> {
match color {
AcadColor::Index(i) => aci_rgb(i),
AcadColor::Rgb { r, g, b } => Some((r, g, b)),
_ => None,
}
}
/// The closest index colour to an RGB value.
pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 {
codec::types::aci_table::nearest_aci(r, g, b)
}
/// `#RRGGBB`, upper case — the same format the Options hex fields store.
pub fn hex_string(r: u8, g: u8, b: u8) -> String {
crate::app::config::rgb_to_hex([r, g, b])
}
/// Parse `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB`. Unlike `config::parse_hex`,
/// which Options uses, this also accepts the short three-digit form.
pub fn parse_hex(text: &str) -> Option<(u8, u8, u8)> {
let digits = text.trim().trim_start_matches('#');
if !digits.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let expanded: String = match digits.len() {
3 => digits.chars().flat_map(|c| [c, c]).collect(),
6 => digits.to_string(),
_ => return None,
};
let value = u32::from_str_radix(&expanded, 16).ok()?;
Some(((value >> 16) as u8, (value >> 8) as u8, value as u8))
}
/// The short name shown on the Current / New swatch.
pub fn short_name(color: AcadColor) -> String {
match color {
AcadColor::Index(i) => format!("ACI {i}"),
AcadColor::Rgb { r, g, b } => hex_string(r, g, b),
AcadColor::ByLayer => "ByLayer".to_string(),
AcadColor::ByBlock => "ByBlock".to_string(),
AcadColor::None => "None".to_string(),
}
}
/// One-line description for the panel's info line.
pub fn describe(color: AcadColor) -> String {
match color {
AcadColor::Index(i) => {
let (r, g, b) = aci_rgb(i).unwrap_or((128, 128, 128));
format!("ACI {i} · RGB {r}, {g}, {b}")
}
AcadColor::Rgb { r, g, b } => format!("RGB {r}, {g}, {b} · {}", hex_string(r, g, b)),
other => short_name(other),
}
}
/// Black or white text, whichever reads better on the given background.
pub fn ink_is_dark(r: u8, g: u8, b: u8) -> bool {
0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b) > 150.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hsb_round_trips_primary_and_mixed_colours() {
for (r, g, b) in [
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(57, 191, 252),
(120, 200, 80),
(128, 128, 128),
(0, 0, 0),
(255, 255, 255),
] {
let hsb = rgb_to_hsb(r, g, b);
assert_eq!(hsb.to_rgb(), (r, g, b), "round trip of {r},{g},{b}");
}
}
#[test]
fn hue_of_pure_colours() {
assert_eq!(rgb_to_hsb(255, 0, 0).h, 0.0);
assert!((rgb_to_hsb(0, 255, 0).h - 120.0).abs() < 0.01);
assert!((rgb_to_hsb(0, 0, 255).h - 240.0).abs() < 0.01);
assert_eq!(rgb_to_hsb(80, 80, 80).s, 0.0);
}
#[test]
fn hue_wraps_around() {
assert_eq!(hsb_to_rgb(360.0, 1.0, 1.0), (255, 0, 0));
assert_eq!(hsb_to_rgb(-120.0, 1.0, 1.0), hsb_to_rgb(240.0, 1.0, 1.0));
}
#[test]
fn nearest_aci_comes_from_the_codec_table() {
assert_eq!(nearest_aci(255, 0, 0), 1);
assert_eq!(nearest_aci(250, 5, 5), 1);
}
#[test]
fn short_names() {
assert_eq!(short_name(AcadColor::Index(140)), "ACI 140");
assert_eq!(
short_name(AcadColor::Rgb {
r: 57,
g: 191,
b: 252
}),
"#39BFFC"
);
assert_eq!(short_name(AcadColor::ByLayer), "ByLayer");
}
#[test]
fn hex_formats_and_parses() {
assert_eq!(hex_string(57, 191, 252), "#39BFFC");
assert_eq!(parse_hex("#39bffc"), Some((57, 191, 252)));
assert_eq!(parse_hex("39BFFC"), Some((57, 191, 252)));
assert_eq!(parse_hex("#3bf"), Some((0x33, 0xBB, 0xFF)));
assert_eq!(parse_hex("#39BFF"), None);
assert_eq!(parse_hex("zzzzzz"), None);
}
}
@@ -3,6 +3,17 @@
//! 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;
pub mod picker;
pub mod widgets;
pub use math::nearest_aci;
pub use picker::{ColorPicker, ColorPickerMsg, Outcome};
use crate::app::Message;
use crate::t;
use crate::ui::properties::acad_color_display;
@@ -34,42 +45,6 @@ pub fn color_to_aci_string(c: AcadColor) -> String {
}
}
/// Convert an Iced colour chosen by `iced_aw::ColorPicker` into a DWG true
/// colour. ACI-only destinations map it to their closest indexed colour later.
pub fn iced_to_acad_color(color: Color) -> AcadColor {
// Truncate the way the picker labels its channels, so the stored colour
// is the one the dialog showed; rounding drifted dragged values by one
// (#1621).
let byte = |c: f32| (c * 255.0) as u8;
AcadColor::Rgb {
r: byte(color.r),
g: byte(color.g),
b: byte(color.b),
}
}
/// Return the closest CAD Color Index for an RGB colour.
pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 {
let mut best = 7;
let mut best_distance = u32::MAX;
for index in 1..=255 {
let Some((ar, ag, ab)) = codec::types::aci_table::aci_to_rgb(index) else {
continue;
};
let dr = i32::from(r) - i32::from(ar);
let dg = i32::from(g) - i32::from(ag);
let db = i32::from(b) - i32::from(ab);
let distance = (dr * dr + dg * dg + db * db) as u32;
if distance < best_distance {
best = index;
best_distance = distance;
}
}
best
}
/// Decode an ACI integer string back into an `AcadColor`.
pub fn aci_string_to_color(s: &str) -> AcadColor {
match s.trim().parse::<i16>().unwrap_or(256) {
@@ -349,174 +324,3 @@ pub fn drop_down_below<'a>(
}
dd.into()
}
/// Full CAD indexed-colour page used by the standalone Select Color dialog.
///
/// This only edits the pending colour. The caller commits it with
/// `Message::ColorWindowPick`, so choosing a swatch does not immediately close
/// the dialog.
pub fn index_color_page<'a>(
current: AcadColor,
recent_colors: &'a [AcadColor],
) -> Element<'a, Message> {
let swatch_button = |color: AcadColor, size: f32| -> Element<'a, Message> {
let (bg, _) = acad_color_display(color);
let selected = current == color;
button(
text("")
.width(Length::Fixed(size))
.height(Length::Fixed(size)),
)
.on_press(Message::ColorPickerColorChanged(color))
.style(move |theme: &Theme, status| {
let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed);
button::Style {
background: Some(Background::Color(bg)),
border: Border {
color: if selected || hovered {
theme.palette().primary.base.color
} else {
theme.palette().background.neutral.color
},
width: if selected {
2.5
} else if hovered {
1.5
} else {
1.0
},
radius: 1.0.into(),
},
..Default::default()
}
})
.padding(0)
.into()
};
// The first standard indexed CAD colours.
let mut standard = row![].spacing(4);
for idx in 1u8..=9 {
standard = standard.push(swatch_button(AcadColor::Index(idx), 22.0));
}
// ACI 10-249.
//
// Each group of ten indices belongs together. Arrange the 24 colour
// families horizontally and their ten shade/intensity variants vertically.
// This makes the palette read as a colour spectrum instead of a raw numeric
// sequence.
let mut palette_rows = column![].spacing(2);
for variant in 0u8..10 {
let mut palette_row = row![].spacing(2);
for family in 1u8..=24 {
let idx = family * 10 + variant;
palette_row = palette_row.push(swatch_button(AcadColor::Index(idx), 16.0));
}
palette_rows = palette_rows.push(palette_row);
}
// ACI 250-255 are the grayscale entries and are shown separately.
let mut grayscale = row![].spacing(4);
for idx in 250u8..=255 {
grayscale = grayscale.push(swatch_button(AcadColor::Index(idx), 22.0));
}
let recent: Element<'a, Message> = if recent_colors.is_empty() {
text(crate::t!("No recent colors yet"))
.size(10)
.style(|theme: &Theme| iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.55)),
})
.into()
} else {
let mut recent_row = row![].spacing(4);
for &color in recent_colors.iter().take(12) {
recent_row = recent_row.push(swatch_button(color, 22.0));
}
recent_row.into()
};
let selected_text = match current {
AcadColor::Index(i) => {
let (r, g, b) = codec::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128));
format!("ACI {i} RGB {r}, {g}, {b}")
}
AcadColor::Rgb { r, g, b } => crate::tf!("True Color RGB {r}, {g}, {b}").into_owned(),
AcadColor::ByLayer => t!("ByLayer").into_owned(),
AcadColor::ByBlock => t!("ByBlock").into_owned(),
AcadColor::None => t!("None").into_owned(),
};
let tabs = row![
button(text(crate::t!("Index Color")).size(12))
.style(button::primary)
.padding([5, 14]),
button(text(crate::t!("True Color")).size(12))
.on_press(Message::ColorPickerTabChanged(
crate::app::ColorPickerTab::TrueColor
))
.style(button::secondary)
.padding([5, 14]),
]
.spacing(4);
let actions = container(
row![
button(text(crate::t!("Cancel")).size(11))
.on_press(Message::CloseColorPicker)
.style(button::secondary)
.padding([5, 14]),
button(text(crate::t!("OK")).size(11))
.on_press(Message::ColorWindowPick(current))
.style(button::primary)
.padding([5, 18]),
]
.spacing(8),
)
.width(Length::Fill)
.align_x(iced::alignment::Horizontal::Right);
container(
column![
tabs,
text(crate::t!("Standard colors")).size(11),
standard,
text(crate::t!("CAD Color Index (ACI) 10–249")).size(11),
text(crate::t!("Color family → · Shade / intensity ↓"))
.size(9)
.style(|theme: &Theme| iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.65)),
}),
container(palette_rows)
.padding(6)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weakest.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}),
text(crate::t!("Grayscale 250–255")).size(11),
grayscale,
text(crate::t!("Recently used")).size(11),
recent,
text(selected_text).size(11),
actions,
]
.spacing(9),
)
.padding(10)
.width(Length::Fixed(470.0))
.into()
}
File diff suppressed because it is too large. Load diff
+258
View File
@@ -0,0 +1,258 @@
//! Saturation/brightness field for one hue.
//!
//! Drawn as three layered quads instead of pixel by pixel: the solid hue, a
//! white-to-transparent wash from left to right, and a transparent-to-black
//! wash from top to bottom. Saturation runs left to right, brightness bottom
//! to top. Dragging anywhere moves the marker.
use iced::advanced::layout::{self, Layout};
use iced::advanced::widget::{self, tree, Widget};
use iced::advanced::{mouse, renderer, Shell};
use iced::gradient::Linear;
use iced::{
Background, Border, Color, Element, Event, Gradient, Length, Point, Radians, Rectangle,
Renderer, Size, Theme,
};
const RADIUS: f32 = 5.0;
const MARKER: f32 = 16.0;
pub struct ColorArea<'a, Message> {
/// Fully saturated, full-brightness colour of the current hue.
hue_color: Color,
/// The colour under the marker.
marker_color: Color,
saturation: f32,
brightness: f32,
on_change: Box<dyn Fn(f32, f32) -> Message + 'a>,
width: Length,
height: Length,
}
#[derive(Default)]
struct State {
dragging: bool,
}
impl<'a, Message> ColorArea<'a, Message> {
/// `saturation` and `brightness` are 0–1; `on_change` receives the same.
pub fn new(
hue_color: Color,
marker_color: Color,
saturation: f32,
brightness: f32,
on_change: impl Fn(f32, f32) -> Message + 'a,
) -> Self {
Self {
hue_color,
marker_color,
saturation: saturation.clamp(0.0, 1.0),
brightness: brightness.clamp(0.0, 1.0),
on_change: Box::new(on_change),
width: Length::Fill,
height: Length::Fixed(150.0),
}
}
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
fn values_at(bounds: Rectangle, cursor: Point) -> (f32, f32) {
let s = ((cursor.x - bounds.x) / bounds.width.max(1.0)).clamp(0.0, 1.0);
let b = 1.0 - ((cursor.y - bounds.y) / bounds.height.max(1.0)).clamp(0.0, 1.0);
(s, b)
}
}
impl<Message> Widget<Message, Theme, Renderer> for ColorArea<'_, Message> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State::default())
}
fn size(&self) -> Size<Length> {
Size::new(self.width, self.height)
}
fn layout(
&mut self,
_tree: &mut widget::Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::atomic(limits, self.width, self.height)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State>();
let bounds = layout.bounds();
let publish = |shell: &mut Shell<'_, Message>, position: Point| {
let (s, b) = Self::values_at(bounds, position);
shell.publish((self.on_change)(s, b));
shell.capture_event();
};
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(position) = cursor.position_over(bounds) {
state.dragging = true;
publish(shell, position);
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(position) = cursor.position() {
publish(shell, position);
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => {
state.dragging = false;
shell.capture_event();
}
_ => {}
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
if tree.state.downcast_ref::<State>().dragging || cursor.is_over(layout.bounds()) {
mouse::Interaction::Crosshair
} else {
mouse::Interaction::default()
}
}
fn draw(
&self,
_tree: &widget::Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
use iced::advanced::Renderer as _;
let bounds = layout.bounds();
let rounded = renderer::Quad {
bounds,
border: Border {
radius: RADIUS.into(),
..Border::default()
},
..renderer::Quad::default()
};
let wash = |angle: f32, from: Color, to: Color| {
Background::Gradient(Gradient::Linear(
Linear::new(Radians(angle))
.add_stop(0.0, from)
.add_stop(1.0, to),
))
};
renderer.fill_quad(rounded, self.hue_color);
renderer.fill_quad(
rounded,
wash(
std::f32::consts::FRAC_PI_2,
Color::WHITE,
Color {
a: 0.0,
..Color::WHITE
},
),
);
renderer.fill_quad(
renderer::Quad {
border: Border {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.5),
width: 1.0,
radius: RADIUS.into(),
},
..rounded
},
wash(
std::f32::consts::PI,
Color {
a: 0.0,
..Color::BLACK
},
Color::BLACK,
),
);
let centre = Point::new(
bounds.x + bounds.width * self.saturation,
bounds.y + bounds.height * (1.0 - self.brightness),
);
let marker = |size: f32| Rectangle {
x: centre.x - size / 2.0,
y: centre.y - size / 2.0,
width: size,
height: size,
};
renderer.fill_quad(
renderer::Quad {
bounds: marker(MARKER + 2.0),
border: Border {
radius: ((MARKER + 2.0) / 2.0).into(),
..Border::default()
},
..renderer::Quad::default()
},
Color::from_rgba(0.0, 0.0, 0.0, 0.55),
);
renderer.fill_quad(
renderer::Quad {
bounds: marker(MARKER),
border: Border {
color: Color::WHITE,
width: 2.0,
radius: (MARKER / 2.0).into(),
},
..renderer::Quad::default()
},
self.marker_color,
);
}
}
impl<'a, Message: 'a> From<ColorArea<'a, Message>> for Element<'a, Message> {
fn from(area: ColorArea<'a, Message>) -> Self {
Element::new(area)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn corners_map_to_saturation_and_brightness() {
let bounds = Rectangle::new(Point::new(10.0, 20.0), Size::new(200.0, 100.0));
let at = |x, y| ColorArea::<()>::values_at(bounds, Point::new(x, y));
assert_eq!(at(10.0, 20.0), (0.0, 1.0));
assert_eq!(at(210.0, 120.0), (1.0, 0.0));
assert_eq!(at(110.0, 70.0), (0.5, 0.5));
assert_eq!(at(-5.0, 500.0), (0.0, 0.0));
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Widgets the Select Color panel needs that iced does not provide.
mod color_area;
pub use color_area::ColorArea;
+3
View File
@@ -62,6 +62,7 @@ static LAY_ULK: &[u8] = include_bytes!("../../assets/icons/layers/layulk.svg");
// All are black-on-transparent; recolour them at the call site with [`tinted`].
pub static CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg");
pub static CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg");
pub static EYEDROPPER: &[u8] = include_bytes!("../../assets/icons/ui/eyedropper.svg");
pub static PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg");
pub static REFRESH: &[u8] = include_bytes!("../../assets/icons/ui/refresh.svg");
pub static SWAP: &[u8] = include_bytes!("../../assets/icons/ui/swap.svg");
@@ -80,6 +81,8 @@ pub static PIN_ACTIVE: &[u8] = include_bytes!("../../assets/icons/ui/pin_active.
pub static SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg");
pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg");
pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg");
pub static LIST: &[u8] = include_bytes!("../../assets/icons/ui/list.svg");
pub static SEARCH: &[u8] = include_bytes!("../../assets/icons/ui/search.svg");
pub static PREVIEW: &[u8] = include_bytes!("../../assets/icons/ui/preview.svg");
pub static SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg");
pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg");
+7 -3
View File
@@ -157,12 +157,14 @@ fn divider<'a>() -> Element<'a, Message> {
.into()
}
/// `shown` overrides the swatch, so ByLayer can show the layer's colour.
fn logical_row<'a>(
color: AcadColor,
cur_color: AcadColor,
label: &'static str,
shown: Option<iced::Color>,
) -> Element<'a, Message> {
let (bg, _) = acad_color_display(color);
let bg = shown.unwrap_or_else(|| acad_color_display(color).0);
let selected = color == cur_color;
let swatch = container(
@@ -212,13 +214,15 @@ fn more_colors_row<'a>(active_color: AcadColor) -> Element<'a, Message> {
}
/// Builds the complete 5-section color dropdown panel element.
/// `layer_color` is the current layer's colour, shown on the ByLayer row.
pub fn color_dropdown_panel<'a>(
active_color: AcadColor,
recent_colors: &[AcadColor],
layer_color: Option<iced::Color>,
) -> Element<'a, Message> {
// 1. ByLayer and ByBlock
let by_layer = logical_row(AcadColor::ByLayer, active_color, "ByLayer");
let by_block = logical_row(AcadColor::ByBlock, active_color, "ByBlock");
let by_layer = logical_row(AcadColor::ByLayer, active_color, "ByLayer", layer_color);
let by_block = logical_row(AcadColor::ByBlock, active_color, "ByBlock", None);
let section1 = column![by_layer, by_block].spacing(2);
// 2. 45-swatch Quick Pick Grid (5 rows × 9 columns)
+31 -1
View File
@@ -88,6 +88,9 @@ pub struct Ribbon {
pub layer_filter: String,
/// Active object color — ACI / ByLayer / ByBlock.
pub active_color: AcadColor,
/// Colour-book identity (`BOOK$NAME`) of a colour picked from a colour
/// book, with the colour it was picked as; see `active_color_name`.
pub active_color_book: Option<(AcadColor, String)>,
/// Active linetype override ("ByLayer", "Continuous", …).
pub active_linetype: String,
/// Active lineweight.
@@ -145,6 +148,14 @@ pub struct LayerInfo {
pub vp_frozen: Option<(usize, bool)>,
}
/// The colour of layer `name` in the ribbon's layer list.
pub(super) fn layer_color(layers: &[LayerInfo], name: &str) -> Option<Color> {
layers
.iter()
.find(|layer| layer.name == name)
.map(|layer| layer.color)
}
/// Full-screen backdrop for an open ribbon dropdown: closes the dropdown when
/// the user clicks outside the panel. (Cursor motion leaking to the viewport
/// beneath is blocked in `on_viewport_move`, not here — iced 0.14's mouse_area
@@ -183,6 +194,20 @@ fn position_ribbon_dropdown<'a>(
}
impl Ribbon {
/// The current layer's colour: what ByLayer looks like for new objects.
pub fn active_layer_color(&self) -> Option<Color> {
layer_color(&self.layer_infos, &self.active_layer)
}
/// The colour-book identity of the active colour, while the active colour
/// is still the one picked from the book.
pub fn active_color_name(&self) -> Option<String> {
self.active_color_book
.as_ref()
.filter(|(color, _)| *color == self.active_color)
.map(|(_, name)| name.clone())
}
pub fn new() -> Self {
Self {
modules: all_ribbon_modules(),
@@ -207,6 +232,7 @@ impl Ribbon {
layer_infos: vec![],
layer_filter: String::new(),
active_color: AcadColor::ByLayer,
active_color_book: None,
active_linetype: "ByLayer".to_string(),
active_lineweight: LineWeight::ByLayer,
available_linetypes: vec![LinetypeItem {
@@ -1293,7 +1319,11 @@ impl Ribbon {
win: (f32, f32),
recent_colors: &[AcadColor],
) -> Option<Element<'_, Message>> {
let panel = color_dropdown::color_dropdown_panel(self.active_color, recent_colors);
let panel = color_dropdown::color_dropdown_panel(
self.active_color,
recent_colors,
self.active_layer_color(),
);
let (align_right, h_pad, top) =
self.dd_anchor(PROP_COLOR_ID, color_dropdown::PANEL_W, win.0);
+4 -1
View File
@@ -1193,7 +1193,10 @@ pub(super) fn render_large<'a>(item: &RibbonItem, ctx: &RenderCtx<'_>) -> Elemen
.width(Length::Fixed(PROP_W))
};
let (color_swatch, _) = acad_color_display(active_color);
// ByLayer shows the current layer's colour.
let color_swatch = super::layer_color(layer_infos, active_layer)
.filter(|_| active_color == AcadColor::ByLayer)
.unwrap_or_else(|| acad_color_display(active_color).0);
let color_row = prop_row(
crate::ui::color_select::color_display_name(active_color),
PROP_COLOR_ID,
+36 -92
View File
@@ -108,6 +108,8 @@ pub struct Folders {
/// Where `.shx` / `.ttf` fonts dropped in by the user (or downloaded for
/// a drawing) are found.
pub fonts: Option<String>,
/// Where `.acb` colour books are kept and loaded ones copied to.
pub color_books: Option<String>,
}
/// Values read from the current drawing's header rather than from preferences.
@@ -165,22 +167,17 @@ impl<T> fmt::Display for Labelled<T> {
}
}
/// A background swatch that opens the colour wheel when clicked.
/// A colour swatch that opens the Select Color panel when clicked.
///
/// The wheel sits alongside the hex field rather than replacing it: typing
/// The swatch sits beside the hex field rather than replacing it: typing
/// `#1E1E1E` stays the fastest way to reproduce an exact colour, while the
/// wheel is for choosing one by eye.
fn bg_swatch_picker<'a>(
target: crate::app::BgTarget,
rgb: [u8; 3],
open: Option<crate::app::BgTarget>,
) -> Element<'a, Message> {
let colour = iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]);
/// panel is for choosing one by eye.
fn color_swatch<'a>(target: crate::app::UiColor, [r, g, b]: [u8; 3]) -> Element<'a, Message> {
let swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(colour)),
background: Some(Background::Color(iced::Color::from_rgb8(r, g, b))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
@@ -188,18 +185,14 @@ fn bg_swatch_picker<'a>(
},
..Default::default()
});
let underlay = button(swatch)
.on_press(Message::BgPickerOpen(target))
button(swatch)
.on_press(Message::OpenColorWindow(
crate::app::ColorPickTarget::Interface(target),
codec::types::Color::Rgb { r, g, b },
))
.padding(0)
.style(button::text);
iced_aw::ColorPicker::new(
open == Some(target),
colour,
underlay,
Message::BgPickerCancel,
Message::BgPickerSubmit,
)
.into()
.style(button::text)
.into()
}
#[allow(clippy::too_many_arguments)]
@@ -235,7 +228,6 @@ pub fn view_window<'a>(
model_bg_input: &'a str,
paper_bg_input: &'a str,
desk_bg_input: &'a str,
bg_picker: Option<crate::app::BgTarget>,
dirty: bool,
close_confirm: bool,
sizing: crate::ui::modal::ModalSizing,
@@ -315,18 +307,8 @@ pub fn view_window<'a>(
let mut color_controls = column![].spacing(8);
for (index, (label, color)) in colors.into_iter().enumerate() {
let swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(color)),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let [r, g, bl, _] = color.into_rgba8();
let swatch = color_swatch(crate::app::UiColor::Theme(index), [r, g, bl]);
color_controls = color_controls.push(
row![
text(label).size(12).width(110),
@@ -536,61 +518,18 @@ pub fn view_window<'a>(
.spacing(0)
.width(sizing.width);
let crosshair_rgb = crosshair_color.unwrap_or([255, 255, 255]);
let crosshair_swatch =
container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
crosshair_rgb[0],
crosshair_rgb[1],
crosshair_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let command_rgb = command_text_color.unwrap_or([220, 220, 220]);
let command_swatch =
container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
command_rgb[0],
command_rgb[1],
command_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let snap_rgb = snap_marker_color.unwrap_or([255, 230, 26]);
let snap_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
snap_rgb[0],
snap_rgb[1],
snap_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let crosshair_swatch = color_swatch(
crate::app::UiColor::Crosshair,
crosshair_color.unwrap_or([255, 255, 255]),
);
let command_swatch = color_swatch(
crate::app::UiColor::CommandText,
command_text_color.unwrap_or([220, 220, 220]),
);
let snap_swatch = color_swatch(
crate::app::UiColor::SnapMarker,
snap_marker_color.unwrap_or([255, 230, 26]),
);
let model_bg_rgb = model_space
.custom_bg
@@ -750,7 +689,7 @@ pub fn view_window<'a>(
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Model background")).size(12).width(140),
bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb, bg_picker),
color_swatch(crate::app::UiColor::ModelBackground, model_bg_rgb),
text_input("#RRGGBB", model_bg_input)
.on_input(Message::ModelSpaceBgChanged)
.width(150),
@@ -763,7 +702,7 @@ pub fn view_window<'a>(
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Paper background")).size(12).width(140),
bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb, bg_picker),
color_swatch(crate::app::UiColor::PaperBackground, paper_bg_rgb),
text_input("#RRGGBB", paper_bg_input)
.on_input(Message::PaperSpaceBgChanged)
.width(150),
@@ -775,7 +714,7 @@ pub fn view_window<'a>(
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Desk surround")).size(12).width(140),
bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb, bg_picker),
color_swatch(crate::app::UiColor::DeskBackground, desk_bg_rgb),
text_input("#RRGGBB", desk_bg_input)
.on_input(Message::DeskSpaceBgChanged)
.width(150),
@@ -1853,6 +1792,11 @@ pub fn view_window<'a>(
folder_row(crate::t!("Autosave files"), folders.autosave.clone()),
Space::new().height(12),
folder_row(crate::t!("Fonts"), folders.fonts.clone()),
Space::new().height(12),
folder_row(
crate::t!("Color book locations"),
folders.color_books.clone()
),
Space::new().height(16),
text(crate::t!("Default template for new drawings")).size(12),
Space::new().height(4),