From 4697e8cc33b753ffae382255cd0328b376340d3d Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Sun, 9 Aug 2026 23:40:55 -0300 Subject: [PATCH] feat(color): add CAD indexed color picker --- src/app/mod.rs | 23 ++++- src/app/update/mod.rs | 39 ++++++++- src/app/view/mod.rs | 57 ++++++++---- src/ui/color_select.rs | 195 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 293 insertions(+), 21 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index d10711d34..650a39093 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -599,6 +599,11 @@ pub(super) struct OpenCADStudio { // ── Floating panel windows ──────────────────────────────────────────── /// Active `iced_aw` colour picker: destination plus its initial colour. color_pick_target: Option<(ColorPickTarget, AcadColor)>, + /// Visible page in the shared CAD colour picker. + color_picker_tab: ColorPickerTab, + /// Recently committed real colours, newest first. + /// ACI and True Color remain semantically distinct even when their RGB matches. + recent_colors: Vec, /// The open in-canvas modal dialog, if any (Plan B: shared overlay instead /// of OS windows). active_modal: Option, @@ -1162,7 +1167,13 @@ pub struct SaveOutcome { refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } - +/// Active page in the shared CAD colour picker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ColorPickerTab { + #[default] + Index, + TrueColor, +} /// Where a colour chosen in the standalone palette window should be applied. #[derive(Debug, Clone)] pub enum ColorPickTarget { @@ -2904,11 +2915,15 @@ pub enum Message { DsCenterMarkMode(String), /// Toggle the expanded colour palette for a DimStyle colour field. DsColorMore(DsField), - /// Open the `iced_aw` colour picker for a field and its current colour. + /// Open the shared CAD colour picker for a field and its current colour. OpenColorWindow(ColorPickTarget, AcadColor), + /// Switch between Index Color and True Color. + ColorPickerTabChanged(ColorPickerTab), + /// Change the pending colour without committing it yet. + ColorPickerColorChanged(AcadColor), /// Close the colour picker without choosing. CloseColorPicker, - /// A colour was chosen in the `iced_aw` picker. + /// Commit the colour chosen in the shared picker. ColorWindowPick(acadrust::types::Color), /// Set a block/linetype Handle field on the selected dim style from a /// dropdown of available block-records / linetypes (by name). @@ -3127,6 +3142,8 @@ impl OpenCADStudio { last_point: None, main_window: None, color_pick_target: None, + color_picker_tab: ColorPickerTab::Index, + recent_colors: Vec::new(), active_modal: None, find_replace: FindReplaceState::default(), aec_drop_acknowledged: false, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ff5477306..fbdc58d73 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -7321,21 +7321,56 @@ impl OpenCADStudio { } Message::OpenColorWindow(target, color) => { self.color_pick_target = Some((target, color)); + + // Always open the shared CAD colour picker on the indexed ACI page. + self.color_picker_tab = crate::app::ColorPickerTab::Index; + self.modal_offset = iced::Vector::ZERO; self.ds_color_open = None; self.mls_color_open = None; self.ts_color_open = None; self.ribbon.close_dropdown(); + let i = self.active_tab; self.tabs[i].properties.color_picker_open = false; self.tabs[i].layers.color_picker_row = None; - // `color_pick_target.is_some()` drives the iced_aw overlay. + + Task::none() + } + + Message::ColorPickerTabChanged(tab) => { + self.color_picker_tab = tab; + Task::none() + } + Message::ColorPickerColorChanged(color) => { + if let Some((_, current)) = self.color_pick_target.as_mut() { + *current = color; + } Task::none() } Message::CloseColorPicker => { self.color_pick_target = None; Task::none() } - Message::ColorWindowPick(color) => self.on_color_window_pick(color), + + Message::ColorWindowPick(color) => { + // Keep only real colours in the recent list. ByLayer / ByBlock / None + // are logical CAD states rather than reusable colours. + if matches!( + &color, + acadrust::types::Color::Index(_) + | acadrust::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); + } + + self.recent_colors.insert(0, color.clone()); + self.recent_colors.truncate(12); + } + + self.on_color_window_pick(color) + } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 91bc6835e..91c3e1bb1 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1850,24 +1850,49 @@ impl OpenCADStudio { } None => composed.into(), }; - // iced_aw owns the colour-picker overlay and keeps it above whichever - // application modal requested it. + // Shared CAD colour picker. Indexed ACI colours use OpenCADStudio's own + // dialog; True Color keeps the existing iced_aw gradient picker. if let Some((_, current)) = self.color_pick_target.as_ref() { - let initial = crate::ui::properties::acad_color_display(*current).0; - let modal_base = - crate::ui::modal::backdrop(base, Message::CloseColorPicker); - iced_aw::ColorPicker::new( - true, - initial, - modal_base, - Message::CloseColorPicker, - |color| { - Message::ColorWindowPick( - crate::ui::color_select::iced_to_acad_color(color), + match self.color_picker_tab { + super::ColorPickerTab::Index => { + let content = crate::ui::color_select::index_color_page( + *current, + &self.recent_colors, + ); + + crate::ui::modal::modal( + base, + "Select Color", + content, + Message::CloseColorPicker, + self.modal_offset, + crate::ui::modal::ModalOptions::NOTICE, ) - }, - ) - .into() + } + + super::ColorPickerTab::TrueColor => { + let initial = crate::ui::properties::acad_color_display(*current).0; + let modal_base = + crate::ui::modal::backdrop(base, Message::CloseColorPicker); + + iced_aw::ColorPicker::new( + true, + initial, + modal_base, + + // Cancel inside True Color returns to the indexed page instead + // of closing the whole CAD colour picker. + Message::ColorPickerTabChanged(super::ColorPickerTab::Index), + + |color| { + Message::ColorWindowPick( + crate::ui::color_select::iced_to_acad_color(color), + ) + }, + ) + .into() + } + } } else { base } diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index ccc2cf842..da592bebe 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -244,3 +244,198 @@ pub fn drop_down_below<'a>( .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() +}