forked from 1b-agent/opencadstudio-upstream
442 lines
14 KiB
Rust
442 lines
14 KiB
Rust
//! Shared colour selector: a dropdown-style button that opens a list of named
|
||
//! colours (each shown with its swatch) plus the full ACI palette. Used by the
|
||
//! properties panel and every style editor so colour selection looks and
|
||
//! behaves the same everywhere.
|
||
|
||
use crate::app::Message;
|
||
use crate::ui::properties::acad_color_display;
|
||
use acadrust::types::Color as AcadColor;
|
||
use iced::widget::{button, column, container, row, text};
|
||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||
use crate::t;
|
||
|
||
/// Which "logical" entries the colour list offers besides the standard ACI
|
||
/// colours.
|
||
#[derive(Clone, Copy, Default)]
|
||
pub struct ColorExtras {
|
||
pub by_layer: bool,
|
||
pub by_block: bool,
|
||
}
|
||
|
||
/// Encode a colour as the ACI integer string the style editors store
|
||
/// (ByBlock=0, ByLayer=256, indexed 1-255). True colours are mapped to the
|
||
/// closest ACI entry because these fields cannot store RGB values.
|
||
pub fn color_to_aci_string(c: AcadColor) -> String {
|
||
match c {
|
||
AcadColor::ByBlock => "0".to_string(),
|
||
AcadColor::ByLayer => "256".to_string(),
|
||
AcadColor::None => "257".to_string(),
|
||
AcadColor::Index(i) => i.to_string(),
|
||
AcadColor::Rgb { r, g, b } => nearest_aci(r, g, b).to_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 {
|
||
let [r, g, b, _] = color.into_rgba8();
|
||
AcadColor::Rgb { r, g, b }
|
||
}
|
||
|
||
/// Return the closest AutoCAD 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)) = acadrust::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) {
|
||
0 => AcadColor::ByBlock,
|
||
256 => AcadColor::ByLayer,
|
||
257 => AcadColor::None,
|
||
n if (1..=255).contains(&n) => AcadColor::Index(n as u8),
|
||
_ => AcadColor::ByLayer,
|
||
}
|
||
}
|
||
|
||
/// Display name for a colour: the standard name for ACI 1-9 / ByLayer /
|
||
/// ByBlock, otherwise the "R,G,B" values (0-255) so unnamed palette colours
|
||
/// read meaningfully.
|
||
pub fn color_display_name(c: AcadColor) -> String {
|
||
let (_, label) = acad_color_display(c);
|
||
if label == "Index" || label == "Custom" {
|
||
match c {
|
||
AcadColor::Index(i) => {
|
||
let (r, g, b) =
|
||
acadrust::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128));
|
||
format!("{r},{g},{b}")
|
||
}
|
||
AcadColor::Rgb { r, g, b } => format!("{r},{g},{b}"),
|
||
_ => label.to_string(),
|
||
}
|
||
} else {
|
||
t!(label).into_owned()
|
||
}
|
||
}
|
||
|
||
/// A small colour square.
|
||
fn swatch<'a>(bg: Color) -> Element<'a, Message> {
|
||
container(text("").width(13).height(13))
|
||
.style(move |theme: &Theme| container::Style {
|
||
background: Some(Background::Color(bg)),
|
||
border: Border {
|
||
color: theme.palette().background.neutral.color,
|
||
width: 1.0,
|
||
radius: 2.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.width(13)
|
||
.height(13)
|
||
.into()
|
||
}
|
||
|
||
/// Build a colour selector.
|
||
///
|
||
/// * `current` — the currently selected colour (shown on the button).
|
||
/// * `open` — whether the colour list / palette is expanded.
|
||
/// * `extras` — whether ByLayer / ByBlock appear in the list.
|
||
/// * `on_select` — called with the chosen colour.
|
||
/// * `on_toggle` — opens / closes the list.
|
||
pub fn color_selector<'a>(
|
||
current: AcadColor,
|
||
open: bool,
|
||
extras: ColorExtras,
|
||
on_select: impl Fn(AcadColor) -> Message + 'a,
|
||
on_toggle: Message,
|
||
on_more: Message,
|
||
) -> Element<'a, Message> {
|
||
let (cur_bg, _) = acad_color_display(current);
|
||
let cur_name = color_display_name(current);
|
||
let on_dismiss = on_toggle.clone();
|
||
|
||
// Closed button: current swatch + name + caret.
|
||
let head = button(
|
||
row![
|
||
swatch(cur_bg),
|
||
text(cur_name).size(11),
|
||
crate::ui::icons::themed_arrow_toggle(open, 9.0),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::Center),
|
||
)
|
||
.on_press(on_toggle)
|
||
.padding([3, 6])
|
||
.width(Length::Fill);
|
||
|
||
if !open {
|
||
return head.into();
|
||
}
|
||
|
||
let popup = container(color_list(extras, on_select, on_more))
|
||
.style(|theme: &Theme| {
|
||
let palette = theme.palette();
|
||
container::Style {
|
||
background: Some(Background::Color(palette.background.weak.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 2.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.padding(5)
|
||
.width(220);
|
||
|
||
// `DropDown` keeps the popup outside the surrounding form layout and
|
||
// handles viewport placement, Escape, and outside-click dismissal.
|
||
iced_aw::DropDown::new(head, popup, true)
|
||
.width(220)
|
||
.alignment(iced_aw::drop_down::Alignment::Bottom)
|
||
.offset(2.0)
|
||
.on_dismiss(on_dismiss)
|
||
.into()
|
||
}
|
||
|
||
fn list_row_style(theme: &Theme, status: button::Status) -> button::Style {
|
||
let palette = theme.palette();
|
||
let hovered = matches!(status, button::Status::Hovered);
|
||
let text_color = if hovered {
|
||
palette.background.strong.text
|
||
} else {
|
||
palette.background.base.text
|
||
};
|
||
button::Style {
|
||
background: hovered.then_some(Background::Color(palette.background.strong.color)),
|
||
text_color,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// The colour list shown inside a picker popup: named ACI colours (with
|
||
/// swatches) plus a "More…" entry that opens the full palette window. Shared by
|
||
/// `color_selector` and the ribbon's colour overlay.
|
||
pub fn color_list<'a>(
|
||
extras: ColorExtras,
|
||
on_select: impl Fn(AcadColor) -> Message + 'a,
|
||
on_more: Message,
|
||
) -> Element<'a, Message> {
|
||
let named_row = |color: AcadColor| -> Element<'a, Message> {
|
||
let (bg, name) = acad_color_display(color);
|
||
button(
|
||
row![swatch(bg), text(t!(name)).size(11)]
|
||
.spacing(5)
|
||
.align_y(iced::Center),
|
||
)
|
||
.on_press(on_select(color))
|
||
.style(list_row_style)
|
||
.padding([2, 4])
|
||
.width(Length::Fill)
|
||
.into()
|
||
};
|
||
|
||
let mut list = column![].spacing(1);
|
||
if extras.by_layer {
|
||
list = list.push(named_row(AcadColor::ByLayer));
|
||
}
|
||
if extras.by_block {
|
||
list = list.push(named_row(AcadColor::ByBlock));
|
||
}
|
||
for i in 1u8..=9 {
|
||
list = list.push(named_row(AcadColor::Index(i)));
|
||
}
|
||
list = list.push(
|
||
button(text(t!("More…")).size(11))
|
||
.on_press(on_more)
|
||
.style(list_row_style)
|
||
.padding([2, 4])
|
||
.width(Length::Fill),
|
||
);
|
||
list.into()
|
||
}
|
||
|
||
/// Render `base` inline with `popup` in an `iced_aw` dropdown.
|
||
pub fn drop_down_below<'a>(
|
||
base: Element<'a, Message>,
|
||
popup: Element<'a, Message>,
|
||
popup_width: Length,
|
||
popup_height: Length,
|
||
on_dismiss: Message,
|
||
) -> Element<'a, Message> {
|
||
iced_aw::DropDown::new(base, popup, true)
|
||
.width(popup_width)
|
||
.height(popup_height)
|
||
.alignment(iced_aw::drop_down::Alignment::Bottom)
|
||
.offset(2.0)
|
||
.on_dismiss(on_dismiss)
|
||
.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()
|
||
};
|
||
|
||
// AutoCAD's first standard indexed 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("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) =
|
||
acadrust::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128));
|
||
format!("ACI {i} RGB {r}, {g}, {b}")
|
||
}
|
||
AcadColor::Rgb { r, g, b } => {
|
||
format!("True Color RGB {r}, {g}, {b}")
|
||
}
|
||
AcadColor::ByLayer => "ByLayer".to_string(),
|
||
AcadColor::ByBlock => "ByBlock".to_string(),
|
||
AcadColor::None => "None".to_string(),
|
||
};
|
||
|
||
let tabs = row![
|
||
button(text("Index Color").size(12))
|
||
.style(button::primary)
|
||
.padding([5, 14]),
|
||
button(text("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("Cancel").size(11))
|
||
.on_press(Message::CloseColorPicker)
|
||
.style(button::secondary)
|
||
.padding([5, 14]),
|
||
button(text("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("Standard colors").size(11),
|
||
standard,
|
||
text("AutoCAD Color Index (ACI) 10–249").size(11),
|
||
|
||
text("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("Grayscale 250–255").size(11),
|
||
|
||
grayscale,
|
||
|
||
text("Recently used").size(11),
|
||
|
||
recent,
|
||
|
||
text(selected_text).size(11),
|
||
|
||
actions,
|
||
]
|
||
.spacing(9),
|
||
)
|
||
.padding(10)
|
||
.width(Length::Fixed(470.0))
|
||
.into()
|
||
}
|