From cefa25882380b9bba95283d7bb5cbd37701c429e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:56:52 +0000 Subject: [PATCH] Share the colour editor, add an eyedropper, use library code - ColorEditor: the HSB / RGB / field editor and hex box, now shared by the Select Color panel and the Options background swatches. Those swatches open the panel without tabs or index colours, which removes the iced_aw color_picker feature and the BgPicker messages. - Sliders are iced's built-in slider and vertical_slider over a gradient container; the custom GradientSlider widget is gone. - Nearest index colour comes from opencadcodec's aci_table::nearest_aci. - Eyedropper: a pipette at the right of the mode row hides the panel, captures the window with iced's screenshot task and shows an 11x11 magnifier beside a crosshair; a click picks the pixel, Esc backs out. Not offered on the web build. - The hex box applies six-digit values while typing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- Cargo.toml | 2 +- assets/icons/ui/eyedropper.svg | 1 + src/app/mod.rs | 14 +- src/app/update/mod.rs | 52 +- src/app/update/style.rs | 24 +- src/app/view/mod.rs | 5 + src/app/view/modal.rs | 1 - src/ui/color_select/editor.rs | 484 ++++++++ src/ui/color_select/eyedropper.rs | 185 +++ src/ui/color_select/math.rs | 29 +- src/ui/color_select/mod.rs | 6 +- src/ui/color_select/picker.rs | 1012 ++++++----------- .../color_select/widgets/gradient_slider.rs | 319 ------ src/ui/color_select/widgets/mod.rs | 2 - src/ui/icons.rs | 1 + src/ui/window/options.rs | 41 +- 16 files changed, 1125 insertions(+), 1053 deletions(-) create mode 100644 assets/icons/ui/eyedropper.svg create mode 100644 src/ui/color_select/editor.rs create mode 100644 src/ui/color_select/eyedropper.rs delete mode 100644 src/ui/color_select/widgets/gradient_slider.rs diff --git a/Cargo.toml b/Cargo.toml index 89d31c359..19056d1c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ url = "2" iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] } iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } -iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "color_picker", "drop_down", "number_input"] } +iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "drop_down", "number_input"] } bytemuck = { version = "1.25", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" diff --git a/assets/icons/ui/eyedropper.svg b/assets/icons/ui/eyedropper.svg new file mode 100644 index 000000000..fe34cd3f9 --- /dev/null +++ b/assets/icons/ui/eyedropper.svg @@ -0,0 +1 @@ + diff --git a/src/app/mod.rs b/src/app/mod.rs index 1ec39933d..1c9cacc71 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -838,9 +838,6 @@ pub(super) struct OpenCADStudio { pub(crate) sheet_set_pending_layout: Option<(String, String)>, /// The subset / set Import Layout as Sheet adds to, while its file picker is open. pub(crate) sheet_set_import_parent: Option, - /// Which viewport background the colour wheel is editing, or `None` when - /// it is closed. One slot, because only one wheel can be open at a time. - pub(crate) bg_picker: Option, /// General edge-stack dock layout for the side panels. pub(crate) dock: crate::ui::dock::DockState, /// Which panel is currently floated at full height (hovered, or a pinned @@ -1580,7 +1577,7 @@ pub struct SaveOutcome { refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } -/// Which viewport background a colour-wheel session is editing. +/// Which viewport background the interface colour picker is editing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BgTarget { Model, @@ -1610,6 +1607,8 @@ pub enum ColorPickTarget { LayerState(usize), /// The MText editor's selection (or global) colour. MText, + /// An Options viewport background (interface colour, true colour only). + Background(BgTarget), } /// Table records the clipboard entities depend on, snapshotted from the source @@ -2311,12 +2310,6 @@ pub enum Message { ModelSpaceModeChanged(config::ModelSpaceMode), /// Change Model Space custom background color as hex or empty for default. ModelSpaceBgChanged(String), - /// Open the colour wheel on one of the viewport backgrounds. - BgPickerOpen(BgTarget), - /// Dismiss the wheel, changing nothing. - BgPickerCancel, - /// Accept the wheel's colour for whichever background it was opened on. - BgPickerSubmit(iced::Color), /// Change Paper Space custom sheet background color as hex or empty for default. PaperSpaceBgChanged(String), /// Change Paper Space desk surround background (#RRGGBB). @@ -4283,7 +4276,6 @@ impl OpenCADStudio { sheet_set: Default::default(), sheet_set_pending_layout: None, sheet_set_import_parent: None, - bg_picker: None, block_palette: Default::default(), xref_manager: Default::default(), dock: Default::default(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a65093e5d..14af0ca72 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8256,39 +8256,6 @@ impl OpenCADStudio { Task::none() } - Message::BgPickerOpen(target) => { - self.bg_picker = Some(target); - Task::none() - } - - Message::BgPickerCancel => { - self.bg_picker = None; - Task::none() - } - - Message::BgPickerSubmit(color) => { - // Hand the result to the same handler the typed hex field - // uses, so the wheel and the field cannot drift apart on - // validation, persistence or the MatchTheme fallback. - let hex = crate::app::config::rgb_to_hex([ - (color.r * 255.0).round() as u8, - (color.g * 255.0).round() as u8, - (color.b * 255.0).round() as u8, - ]); - match self.bg_picker.take() { - Some(crate::app::BgTarget::Model) => { - Task::done(Message::ModelSpaceBgChanged(hex)) - } - Some(crate::app::BgTarget::Paper) => { - Task::done(Message::PaperSpaceBgChanged(hex)) - } - Some(crate::app::BgTarget::Desk) => { - Task::done(Message::DeskSpaceBgChanged(hex)) - } - None => Task::none(), - } - } - Message::ModelSpaceBgChanged(hex) => { self.model_bg_input = hex.clone(); if hex.trim().is_empty() { @@ -11066,6 +11033,16 @@ impl OpenCADStudio { self.color_picker = None; Task::none() } + Some(crate::ui::color_select::Outcome::Screenshot) => { + use crate::ui::color_select::{eyedropper::EyedropperMsg, ColorPickerMsg}; + self.main_window.map_or_else(Task::none, |id| { + iced::window::screenshot(id).map(|shot| { + Message::ColorPicker(ColorPickerMsg::Eyedropper( + EyedropperMsg::Shot(shot), + )) + }) + }) + } None => Task::none(), } } @@ -11075,7 +11052,14 @@ impl OpenCADStudio { } Message::ColorWindowPick(color) => { - self.note_recent_color(color); + // Interface colours are not drawing colours: keep them out of + // the recent list. + if !matches!( + self.color_picker.as_ref().map(|picker| &picker.target), + Some(crate::app::ColorPickTarget::Background(_)) + ) { + self.note_recent_color(color); + } self.on_color_window_pick(color) } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 97994f40c..0db169c56 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1136,6 +1136,25 @@ impl OpenCADStudio { } pub(super) fn on_color_window_pick(&mut self, color: codec::types::Color) -> Task { + if let Some(crate::app::ColorPickTarget::Background(target)) = self + .color_picker + .as_ref() + .map(|picker| picker.target.clone()) + { + self.color_picker = None; + let Some((r, g, b)) = crate::ui::color_select::math::color_rgb(color) else { + return Task::none(); + }; + // Hand the result to the same handler the typed hex field uses, so + // the picker and the field cannot drift apart on validation, + // persistence or the MatchTheme fallback. + let hex = crate::app::config::rgb_to_hex([r, g, b]); + return Task::done(match target { + crate::app::BgTarget::Model => Message::ModelSpaceBgChanged(hex), + crate::app::BgTarget::Paper => Message::PaperSpaceBgChanged(hex), + crate::app::BgTarget::Desk => Message::DeskSpaceBgChanged(hex), + }); + } if matches!( self.color_picker.as_ref().map(|picker| &picker.target), Some(crate::app::ColorPickTarget::PlotStyle) @@ -1187,7 +1206,10 @@ impl OpenCADStudio { Some(crate::app::ColorPickTarget::LayerState(idx)) => { Some(Message::LayerStateEditorLayerColor(idx, color)) } - Some(crate::app::ColorPickTarget::PlotStyle) => None, + // Handled before this match. + Some( + crate::app::ColorPickTarget::PlotStyle | crate::app::ColorPickTarget::Background(_), + ) => None, None => None, }; if let Some(m) = edit { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 19dbec8ab..1f796a161 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2475,6 +2475,11 @@ bg={bg_ms:.1}ms n={view_count}" } if let Some(picker) = self.color_picker.as_ref() { + // While the eyedropper is picking, the panel steps aside so the + // whole window can be sampled. + if let Some(eyedropper) = picker.eyedropper_view() { + return iced::widget::stack![base, eyedropper].into(); + } crate::ui::modal::modal( base, t!("Select Color"), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 5cf0dbbd4..e3add1285 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -380,7 +380,6 @@ impl OpenCADStudio { &self.model_bg_input, &self.paper_bg_input, &self.desk_bg_input, - self.bg_picker, dirty, close_confirm, flow, diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs new file mode 100644 index 000000000..770fbfcc6 --- /dev/null +++ b/src/ui/color_select/editor.rs @@ -0,0 +1,484 @@ +//! The colour editor: HSB or RGB sliders, or 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 FIELD_HEIGHT: f32 = 118.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EditorMode { + #[default] + Hsb, + Rgb, + Field, +} + +#[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), + HexInput(String), + HexSubmit, +} + +pub struct ColorEditor { + mode: EditorMode, + /// Kept beside `rgb` so hue and saturation survive grays and black. + hsb: Hsb, + rgb: (u8, u8, u8), + hex_draft: Option, +} + +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, + }, + rgb, + hex_draft: None, + }; + editor.set_rgb(rgb); + editor + } + + pub fn rgb(&self) -> (u8, u8, u8) { + self.rgb + } + + pub fn mode(&self) -> EditorMode { + self.mode + } + + 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.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.hex_draft = None; + } + + /// 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::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). + pub fn view<'a>( + &'a self, + mode_end: Option>, + hex_end: Option>, + ) -> Element<'a, Message> { + let Hsb { h, s, b } = self.hsb; + let (r, g, bl) = self.rgb; + + let mode_button = |content: Element<'a, Message>, mode: EditorMode| { + let active = self.mode == mode; + button(container(content).center_y(Length::Fill)) + .on_press(msg(EditorMsg::Mode(mode))) + .height(28) + .padding([0, 10]) + .style(move |theme: &Theme, status| { + super::picker::toggle_style(theme, active, status) + }) + }; + let field_icon = container(Space::new()) + .width(26) + .height(16) + .style(|_theme: &Theme| { + gradient_box( + 3.0 * std::f32::consts::FRAC_PI_4, + &[ + (0.0, Color::WHITE), + (0.5, Color::from_rgb8(0x3A, 0xA0, 0xFF)), + (1.0, Color::BLACK), + ], + ) + }); + let mut modes = row![ + mode_button(text("HSB").size(12).into(), EditorMode::Hsb), + mode_button(text("RGB").size(12).into(), EditorMode::Rgb), + mode_button(field_icon.into(), EditorMode::Field), + Space::new().width(Length::Fill), + ] + .spacing(6); + if let Some(end) = mode_end { + modes = modes.push(end); + } + + let editor: Element<'a, Message> = match self.mode { + EditorMode::Hsb => 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, + ), + ] + .spacing(10) + .into(), + 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) + ), + ] + .spacing(10) + .into() + } + EditorMode::Field => { + 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(Length::Fixed(FIELD_HEIGHT)); + // A vertical slider grows upwards; the strip runs red at the top. + let strip = stack![ + container(Space::new()) + .width(STRIP_WIDTH) + .height(FIELD_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(FIELD_HEIGHT) + .style(handle_style), + ]; + let value = |letter: &'static str, + v: f32, + max: f32, + unit: char, + f: fn(f32) -> EditorMsg| { + row![ + text(letter).size(12).font(iced::Font::MONOSPACE), + number_box(v, max, Some(unit), f), + ] + .spacing(4) + .align_y(iced::Center) + }; + column![ + row![area, strip].spacing(12), + row![ + value("H", h, 360.0, '°', EditorMsg::Hue), + Space::new().width(Length::Fill), + value("S", s * 100.0, 100.0, '%', EditorMsg::Saturation), + Space::new().width(Length::Fill), + value("B", b * 100.0, 100.0, '%', EditorMsg::Brightness), + ] + .align_y(iced::Center), + ] + .spacing(10) + .into() + } + }; + + let hex = self + .hex_draft + .clone() + .unwrap_or_else(|| math::hex_string(r, g, bl)); + 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, 8]) + .width(Length::Fixed(92.0)), + Space::new().width(Length::Fill), + ] + .spacing(8) + .align_y(iced::Center); + if let Some(end) = hex_end { + hex_row = hex_row.push(end); + } + + column![modes, editor, hex_row].spacing(12).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, + 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, + 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(68.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: 6.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)); + } +} diff --git a/src/ui/color_select/eyedropper.rs b/src/ui/color_select/eyedropper.rs new file mode 100644 index 000000000..87c839356 --- /dev/null +++ b/src/ui/color_select/eyedropper.rs @@ -0,0 +1,185 @@ +//! 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::{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, + Cancel, +} + +#[derive(Default)] +pub struct Eyedropper { + shot: Option, + requested: bool, + cursor: Option, +} + +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); + 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; + let x = if cursor.x + OFFSET + size > window_w { + cursor.x - OFFSET - size + } else { + cursor.x + OFFSET + }; + let y = if cursor.y + OFFSET + size + 20.0 > window_h { + cursor.y - OFFSET - size - 20.0 + } else { + cursor.y + OFFSET + }; + stack![catcher, pin(loupe).x(x).y(y)].into() + } +} + +fn rgb((r, g, b): (u8, u8, u8)) -> Color { + Color::from_rgb8(r, g, b) +} + +/// 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))); + } +} diff --git a/src/ui/color_select/math.rs b/src/ui/color_select/math.rs index a2b6b9c0e..4a01eff3a 100644 --- a/src/ui/color_select/math.rs +++ b/src/ui/color_select/math.rs @@ -73,24 +73,9 @@ pub fn color_rgb(color: AcadColor) -> Option<(u8, u8, u8)> { } } -/// The closest index colour to an RGB value, and whether it is an exact match. -pub fn nearest_aci_match(r: u8, g: u8, b: u8) -> (u8, bool) { - let mut best = 7; - let mut best_distance = u32::MAX; - for index in 1..=255 { - let Some((ar, ag, ab)) = aci_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, best_distance == 0) +/// 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. @@ -222,11 +207,9 @@ mod tests { } #[test] - fn nearest_aci_reports_exact_matches() { - assert_eq!(nearest_aci_match(255, 0, 0), (1, true)); - let (index, exact) = nearest_aci_match(250, 5, 5); - assert_eq!(index, 1); - assert!(!exact); + 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] diff --git a/src/ui/color_select/mod.rs b/src/ui/color_select/mod.rs index 5825e4d56..591c1137f 100644 --- a/src/ui/color_select/mod.rs +++ b/src/ui/color_select/mod.rs @@ -3,11 +3,13 @@ //! properties panel and every style editor so colour selection looks and //! behaves the same everywhere. +pub mod editor; +pub mod eyedropper; pub mod math; pub mod picker; pub mod widgets; -pub use picker::{ColorPicker, ColorPickerMsg, ColorPickerTab, Outcome, TrueColorMode}; +pub use picker::{ColorPicker, ColorPickerMsg, ColorPickerTab, Outcome}; use crate::app::Message; use crate::t; @@ -42,7 +44,7 @@ pub fn color_to_aci_string(c: AcadColor) -> String { /// Return the closest CAD Color Index for an RGB colour. pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 { - math::nearest_aci_match(r, g, b).0 + math::nearest_aci(r, g, b) } /// Decode an ACI integer string back into an `AcadColor`. diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index f30221c26..bc13a55bf 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -2,9 +2,12 @@ //! //! The panel edits a pending colour and only reports it back on OK, so every //! caller keeps applying colours through the same `ColorWindowPick` path. +//! Opened for an interface colour (an Options background) it shows only the +//! colour editor: no tabs, no index colours. -use super::math::{self, Hsb}; -use super::widgets::{ColorArea, GradientSlider}; +use super::editor::{rgb_color, ColorEditor, EditorMsg}; +use super::eyedropper::{Eyedropper, EyedropperMsg}; +use super::math; use crate::app::{ArrowKey, ColorPickTarget, Message}; use crate::t; use crate::ui::properties::acad_color_display; @@ -14,7 +17,8 @@ use iced::{Background, Border, Color, Element, Length, Theme}; const PANEL_WIDTH: f32 = 400.0; const BODY_HEIGHT: f32 = 352.0; -const CONTROL_HEIGHT: f32 = 28.0; +/// The interface picker has no tabs or info line, only the editor. +const EDITOR_ONLY_HEIGHT: f32 = 258.0; const SWATCH_WIDTH: f32 = 104.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -24,36 +28,20 @@ pub enum ColorPickerTab { TrueColor, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum TrueColorMode { - #[default] - Hsb, - Rgb, - Gradient, -} - #[derive(Debug, Clone)] pub enum ColorPickerMsg { Tab(ColorPickerTab), - Mode(TrueColorMode), /// A swatch was clicked. Pick(AcadColor), /// Set the new colour back to the current one. Revert, - Hue(f32), - Saturation(f32), - Brightness(f32), - SaturationBrightness(f32, f32), - Red(u8), - Green(u8), - Blue(u8), + Editor(EditorMsg), FieldInput(String), FieldSubmit, - HexInput(String), - HexSubmit, /// Replace the true colour with its nearest index colour. UseNearest, Arrow(ArrowKey), + Eyedropper(EyedropperMsg), Ok, Cancel, } @@ -63,6 +51,8 @@ pub enum ColorPickerMsg { pub enum Outcome { Commit(AcadColor), Cancel, + /// Capture the window for the eyedropper. + Screenshot, } pub struct ColorPicker { @@ -73,11 +63,9 @@ pub struct ColorPicker { varies: bool, pending: AcadColor, tab: ColorPickerTab, - mode: TrueColorMode, - /// Kept separately from `pending` so the hue survives grays and black. - hsb: Hsb, + editor: ColorEditor, field_draft: Option, - hex_draft: Option, + eyedropper: Option, } impl ColorPicker { @@ -87,23 +75,16 @@ impl ColorPicker { } else { ColorPickerTab::Index }; - let mut picker = Self { + Self { target, current, varies, pending: current, tab, - mode: TrueColorMode::default(), - hsb: Hsb { - h: 0.0, - s: 0.0, - b: 1.0, - }, + editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))), field_draft: None, - hex_draft: None, - }; - picker.set_pending(current); - picker + eyedropper: None, + } } pub fn pending(&self) -> AcadColor { @@ -114,107 +95,85 @@ impl ColorPicker { self.tab } - pub fn mode(&self) -> TrueColorMode { - self.mode + /// Interface colours (Options backgrounds) only get the colour editor. + fn editor_only(&self) -> bool { + matches!(self.target, ColorPickTarget::Background(_)) } fn set_pending(&mut self, color: AcadColor) { self.pending = color; self.field_draft = None; - self.hex_draft = None; - if let Some((r, g, b)) = math::color_rgb(color) { - let next = math::rgb_to_hsb(r, g, b); - self.hsb = Hsb { - // A gray or black has no hue of its own; keep the last one so - // the sliders and field don't jump back to red. - h: if next.s == 0.0 || next.b == 0.0 { - self.hsb.h - } else { - next.h - }, - s: if next.b == 0.0 { self.hsb.s } else { next.s }, - b: next.b, - }; + if let Some(rgb) = math::color_rgb(color) { + self.editor.set_rgb(rgb); } } - 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), - }; - let (r, g, b) = self.hsb.to_rgb(); - self.pending = AcadColor::Rgb { r, g, b }; - self.field_draft = None; - self.hex_draft = None; - } - - fn set_rgb(&mut self, r: u8, g: u8, b: u8) { - self.set_pending(AcadColor::Rgb { r, g, b }); - } - - /// RGB of the pending colour; logical colours fall back to the sliders. fn rgb(&self) -> (u8, u8, u8) { - math::color_rgb(self.pending).unwrap_or_else(|| self.hsb.to_rgb()) + self.editor.rgb() } pub fn update(&mut self, msg: ColorPickerMsg) -> Option { + // While picking from the screen only the eyedropper listens; Esc + // backs out of it rather than closing the panel. + if let Some(eye) = self.eyedropper.as_mut() { + match msg { + ColorPickerMsg::Eyedropper(EyedropperMsg::Move(p)) => { + return eye.track(p).then_some(Outcome::Screenshot); + } + ColorPickerMsg::Eyedropper(EyedropperMsg::Shot(shot)) => eye.set_shot(shot), + ColorPickerMsg::Eyedropper(EyedropperMsg::Pick) => { + if let Some((r, g, b)) = eye.picked() { + self.eyedropper = None; + self.set_pending(AcadColor::Rgb { r, g, b }); + } + } + ColorPickerMsg::Eyedropper(EyedropperMsg::Cancel) | ColorPickerMsg::Cancel => { + self.eyedropper = None; + } + _ => {} + } + return None; + } match msg { ColorPickerMsg::Tab(tab) => self.tab = tab, - ColorPickerMsg::Mode(mode) => self.mode = mode, ColorPickerMsg::Pick(color) => self.set_pending(color), ColorPickerMsg::Revert => self.set_pending(self.current), - ColorPickerMsg::Hue(h) => self.set_hsb(Hsb { h, ..self.hsb }), - ColorPickerMsg::Saturation(s) => self.set_hsb(Hsb { s, ..self.hsb }), - ColorPickerMsg::Brightness(b) => self.set_hsb(Hsb { b, ..self.hsb }), - ColorPickerMsg::SaturationBrightness(s, b) => self.set_hsb(Hsb { s, b, ..self.hsb }), - ColorPickerMsg::Red(red) => { - let (_, g, b) = self.rgb(); - self.set_rgb(red, g, b); - } - ColorPickerMsg::Green(green) => { - let (r, _, b) = self.rgb(); - self.set_rgb(r, green, b); - } - ColorPickerMsg::Blue(blue) => { - let (r, g, _) = self.rgb(); - self.set_rgb(r, g, blue); + ColorPickerMsg::Editor(editor) => { + if self.editor.update(editor) { + let (r, g, b) = self.editor.rgb(); + self.pending = AcadColor::Rgb { r, g, b }; + self.field_draft = None; + } } ColorPickerMsg::FieldInput(value) => self.field_draft = Some(value), ColorPickerMsg::FieldSubmit => { - if let Some(draft) = self.field_draft.take() { - if let Some(color) = math::parse_color_input(&draft) { - self.set_pending(color); - } - } - } - ColorPickerMsg::HexInput(value) => self.hex_draft = Some(value), - ColorPickerMsg::HexSubmit => { - if let Some(draft) = self.hex_draft.take() { - if let Some((r, g, b)) = math::parse_hex(&draft) { - self.set_rgb(r, g, b); - } + if let Some(color) = self + .field_draft + .take() + .and_then(|d| math::parse_color_input(&d)) + { + self.set_pending(color); } } ColorPickerMsg::UseNearest => { let (r, g, b) = self.rgb(); - let (index, _) = math::nearest_aci_match(r, g, b); - self.set_pending(AcadColor::Index(index)); + self.set_pending(AcadColor::Index(math::nearest_aci(r, g, b))); } ColorPickerMsg::Arrow(direction) => { - if self.tab == ColorPickerTab::Index { - if let AcadColor::Index(index) = self.pending { - if let Some(next) = step_index(index, direction) { - self.set_pending(AcadColor::Index(next)); - } + if let (ColorPickerTab::Index, AcadColor::Index(index)) = (self.tab, self.pending) { + if let Some(next) = step_index(index, direction) { + self.set_pending(AcadColor::Index(next)); } } } + ColorPickerMsg::Eyedropper(EyedropperMsg::Start) => { + self.eyedropper = Some(Eyedropper::default()); + } + ColorPickerMsg::Eyedropper(_) => {} ColorPickerMsg::Ok => { // Apply a value still being typed, as if Enter had been pressed. self.update(ColorPickerMsg::FieldSubmit); - self.update(ColorPickerMsg::HexSubmit); + self.update(ColorPickerMsg::Editor(EditorMsg::HexSubmit)); return Some(Outcome::Commit(self.pending)); } ColorPickerMsg::Cancel => return Some(Outcome::Cancel), @@ -222,55 +181,53 @@ impl ColorPicker { None } + /// The full-window eyedropper layer, while it is active. + pub fn eyedropper_view(&self) -> Option> { + self.eyedropper.as_ref().map(Eyedropper::view) + } + pub fn view(&self) -> Element<'_, Message> { - let body = match self.tab { - ColorPickerTab::Index => self.index_tab(), - ColorPickerTab::TrueColor => self.true_color_tab(), + let editor_only = self.editor_only(); + let body = if editor_only || self.tab == ColorPickerTab::TrueColor { + self.true_color_tab() + } else { + self.index_tab() }; - let (sr, sg, sb) = math::color_rgb(self.pending).unwrap_or((128, 128, 128)); - let info = row![ - container(Space::new()) - .width(12) - .height(12) - .style(move |theme: &Theme| swatch_style(theme, rgb_color(sr, sg, sb))), - text(math::describe(self.pending)) - .size(11) - .font(iced::Font::MONOSPACE), - ] - .spacing(8) - .align_y(iced::Center); - - let body = container( - column![ - body, - Space::new().height(Length::Fill), - container(Space::new()) - .width(Length::Fill) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weak.color)), - ..Default::default() - }), - info, - ] - .spacing(8), - ) - .padding(12) - .width(Length::Fill) - .height(Length::Fixed(BODY_HEIGHT)) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weakest.color)), - border: Border { - color: palette.background.weak.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() - } - }); + let mut body = column![body, Space::new().height(Length::Fill)].spacing(8); + if !editor_only { + let fill = + math::color_rgb(self.pending).map_or(Color::from_rgb8(128, 128, 128), rgb_color); + body = body + .push( + container(Space::new()) + .width(Length::Fill) + .height(1) + .style(|theme: &Theme| filled(theme.palette().background.weak.color)), + ) + .push( + row![ + container(Space::new()) + .width(12) + .height(12) + .style(move |_theme: &Theme| swatch_style(fill)), + text(math::describe(self.pending)) + .size(11) + .font(iced::Font::MONOSPACE), + ] + .spacing(8) + .align_y(iced::Center), + ); + } + let body = container(body) + .padding(12) + .width(Length::Fill) + .height(if editor_only { + EDITOR_ONLY_HEIGHT + } else { + BODY_HEIGHT + }) + .style(panel_style); let actions = row![ Space::new().width(Length::Fill), @@ -285,24 +242,25 @@ impl ColorPicker { ] .spacing(8); - column![self.tabs(), self.preview_row(), body, actions] - .spacing(12) - .width(Length::Fixed(PANEL_WIDTH)) + let mut panel = column![].spacing(12).width(PANEL_WIDTH); + if !editor_only { + panel = panel.push(self.tabs()); + } + panel + .push(self.preview_row()) + .push(body) + .push(actions) .into() } fn tabs(&self) -> Element<'_, Message> { let tab = |label: String, tab: ColorPickerTab| { let active = self.tab == tab; - button( - container(text(label).size(12)) - .width(Length::Fill) - .align_x(iced::Center), - ) - .on_press(picker_msg(ColorPickerMsg::Tab(tab))) - .width(Length::Fill) - .padding([5, 4]) - .style(move |theme: &Theme, status| segment_style(theme, active, status)) + button(container(text(label).size(12)).center_x(Length::Fill)) + .on_press(picker_msg(ColorPickerMsg::Tab(tab))) + .width(Length::Fill) + .padding([5, 4]) + .style(move |theme: &Theme, status| segment_style(theme, active, status)) }; container( row![ @@ -312,31 +270,12 @@ impl ColorPicker { .spacing(2), ) .padding(2) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weakest.color)), - border: Border { - color: palette.background.weak.color, - width: 1.0, - radius: 7.0.into(), - }, - ..Default::default() - } - }) + .style(panel_style) .into() } fn preview_row(&self) -> Element<'_, Message> { let caption = |label: String| text(label).size(11).style(muted_text); - - let (current_bg, _) = acad_color_display(self.current); - let (new_bg, _) = acad_color_display(self.pending); - let current_label = if self.varies { - "*VARIES*".to_string() - } else { - logical_badge(self.current) - }; let swatch_text = |label: String, background: Color| { let ink = if ink_is_dark(background) { Color::from_rgb8(0x11, 0x12, 0x14) @@ -354,33 +293,33 @@ impl ColorPicker { ) .center(Length::Fill) }; + let (current_bg, _) = acad_color_display(self.current); + let (new_bg, _) = acad_color_display(self.pending); + let current_label = if self.varies { + "*VARIES*".to_string() + } else { + logical_badge(self.current) + }; let current = button(swatch_text(current_label, current_bg)) .on_press(picker_msg(ColorPickerMsg::Revert)) .padding(0) .width(Length::Fill) .height(Length::Fill) .style(move |_theme: &Theme, _status| button::Style { - background: Some(Background::Color(current_bg)), + background: Some(current_bg.into()), ..Default::default() }); let new = container(swatch_text(logical_badge(self.pending), new_bg)) .width(Length::Fill) .height(Length::Fill) - .style(move |_theme: &Theme| container::Style { - background: Some(Background::Color(new_bg)), - ..Default::default() - }); - let divider = - container(Space::new()) - .width(1) - .height(Length::Fill) - .style(|_theme: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.35))), - ..Default::default() - }); + .style(move |_theme: &Theme| filled(new_bg)); + let divider = container(Space::new()) + .width(1) + .height(Length::Fill) + .style(|_theme: &Theme| filled(Color::from_rgba(0.0, 0.0, 0.0, 0.35))); let swatch = container(row![current, divider, new]) .width(SWATCH_WIDTH) - .height(CONTROL_HEIGHT) + .height(28) .padding(1) .style(|theme: &Theme| container::Style { border: Border { @@ -392,93 +331,84 @@ impl ColorPicker { }) .clip(true); - let field_text = self - .field_draft - .clone() - .unwrap_or_else(|| math::color_field_text(self.pending)); - let field = text_input("", &field_text) - .on_input(|value| picker_msg(ColorPickerMsg::FieldInput(value))) - .on_submit(picker_msg(ColorPickerMsg::FieldSubmit)) - .size(12) - .font(iced::Font::MONOSPACE) - .padding([6, 10]) - .width(Length::Fill); - - row![ - column![ - row![ - caption(t!("Current").into_owned()), - Space::new().width(Length::Fill), - caption(t!("New").into_owned()), - ] - .width(SWATCH_WIDTH), - swatch, + let mut preview = row![column![ + row![ + caption(t!("Current").into_owned()), + Space::new().width(Length::Fill), + caption(t!("New").into_owned()), ] - .spacing(4), - column![caption(t!("Color").into_owned()), field] + .width(SWATCH_WIDTH), + swatch, + ] + .spacing(4)] + .spacing(10) + .align_y(iced::Bottom); + if !self.editor_only() { + let field_text = self + .field_draft + .clone() + .unwrap_or_else(|| math::color_field_text(self.pending)); + preview = preview.push( + column![ + caption(t!("Color").into_owned()), + text_input("", &field_text) + .on_input(|value| picker_msg(ColorPickerMsg::FieldInput(value))) + .on_submit(picker_msg(ColorPickerMsg::FieldSubmit)) + .size(12) + .font(iced::Font::MONOSPACE) + .padding([6, 10]), + ] .spacing(4) .width(Length::Fill), - ] - .spacing(10) - .align_y(iced::Bottom) - .into() + ); + } + preview.into() } fn index_tab(&self) -> Element<'_, Message> { let section = |label: String| text(label).size(11); - - let mut standard = row![].spacing(5); - for index in 1u8..=9 { - standard = standard.push(self.swatch_button(index, 30.0, 26.0)); - } - + let swatches = |range: std::ops::RangeInclusive| { + range.fold(row![].spacing(5), |line, index| { + line.push(self.swatch_button(index, 30.0, 26.0)) + }) + }; // ACI 10–249 come in groups of ten: 24 hue families across, their ten // shades down. - let mut grid = column![].spacing(2); - for shade in 0u8..10 { - let mut line = row![].spacing(2); - for family in 1u8..=24 { - line = line.push(self.swatch_button(family * 10 + shade, 13.0, 13.0)); - } - grid = grid.push(line); - } - - let mut grays = row![].spacing(5); - for index in 250u8..=255 { - grays = grays.push(self.swatch_button(index, 30.0, 26.0)); - } - + let grid = (0u8..10).fold(column![].spacing(2), |grid, shade| { + grid.push((1u8..=24).fold(row![].spacing(2), |line, family| { + line.push(self.swatch_button(family * 10 + shade, 13.0, 13.0)) + })) + }); column![ - column![section(t!("Standard colors").into_owned()), standard].spacing(6), + column![section(t!("Standard colors").into_owned()), swatches(1..=9)].spacing(6), column![ - row![ - section(t!("CAD Color Index (ACI) 10–249").into_owned()), - Space::new().width(Length::Fill), - ], - grid, + section(t!("CAD Color Index (ACI) 10–249").into_owned()), + grid + ] + .spacing(6), + column![ + section(t!("Grayscale 250–255").into_owned()), + swatches(250..=255) ] .spacing(6), - column![section(t!("Grayscale 250–255").into_owned()), grays].spacing(6), ] .spacing(12) .into() } fn swatch_button(&self, index: u8, width: f32, height: f32) -> Element<'_, Message> { - let (r, g, b) = math::aci_rgb(index).unwrap_or((128, 128, 128)); - let fill = rgb_color(r, g, b); + let fill = rgb_color(math::aci_rgb(index).unwrap_or((128, 128, 128))); let selected = self.pending == AcadColor::Index(index); 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); - let accent = theme.palette().primary.base.color; button::Style { - background: Some(Background::Color(fill)), + background: Some(fill.into()), border: Border { color: if selected || hovered { - accent + theme.palette().primary.base.color } else { Color::from_rgba(0.0, 0.0, 0.0, 0.35) }, @@ -498,221 +428,50 @@ impl ColorPicker { } fn true_color_tab(&self) -> Element<'_, Message> { - let mode_button = |content: Element<'static, Message>, mode: TrueColorMode| { - let active = self.mode == mode; - button(container(content).center_y(Length::Fill)) - .on_press(picker_msg(ColorPickerMsg::Mode(mode))) - .height(CONTROL_HEIGHT) - .padding([0, 10]) - .style(move |theme: &Theme, status| toggle_style(theme, active, status)) - }; - let gradient_icon: Element<'static, Message> = container(Space::new()) - .width(26) - .height(16) - .style(|_theme: &Theme| container::Style { - background: Some(Background::Gradient(iced::Gradient::Linear( - iced::gradient::Linear::new(iced::Radians(std::f32::consts::FRAC_PI_4 * 3.0)) - .add_stop(0.0, Color::WHITE) - .add_stop(0.5, Color::from_rgb8(0x3A, 0xA0, 0xFF)) - .add_stop(1.0, Color::BLACK), - ))), - border: Border { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.3), - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }) - .into(); - let modes = row![ - mode_button(text("HSB").size(12).into(), TrueColorMode::Hsb), - mode_button(text("RGB").size(12).into(), TrueColorMode::Rgb), - mode_button(gradient_icon, TrueColorMode::Gradient), - ] - .spacing(6); - - let editor = match self.mode { - TrueColorMode::Hsb => self.hsb_sliders(), - TrueColorMode::Rgb => self.rgb_sliders(), - TrueColorMode::Gradient => self.gradient_editor(), - }; - - column![modes, editor, self.hex_row()].spacing(12).into() - } - - fn hsb_sliders(&self) -> Element<'_, Message> { - let Hsb { h, s, b } = self.hsb; - let hue_stops = hue_stops(); - let sat_stops = vec![(0.0, hsb_color(h, 0.0, b)), (1.0, hsb_color(h, 1.0, b))]; - let bright_stops = vec![(0.0, Color::BLACK), (1.0, hsb_color(h, s, 1.0))]; - column![ - slider_row( - "H", - GradientSlider::new(h / 360.0, hue_stops, |v| picker_msg(ColorPickerMsg::Hue( - v * 360.0 - ))) - .wheel_step(1.0 / 360.0), - number_box(h.round() as u16, 360, Some('°'), |v| { - picker_msg(ColorPickerMsg::Hue(f32::from(v))) - }), - ), - slider_row( - "S", - GradientSlider::new(s, sat_stops, |v| picker_msg(ColorPickerMsg::Saturation(v))), - number_box((s * 100.0).round() as u16, 100, Some('%'), |v| { - picker_msg(ColorPickerMsg::Saturation(f32::from(v) / 100.0)) - }), - ), - slider_row( - "B", - GradientSlider::new(b, bright_stops, |v| picker_msg(ColorPickerMsg::Brightness( - v - ))), - number_box((b * 100.0).round() as u16, 100, Some('%'), |v| { - picker_msg(ColorPickerMsg::Brightness(f32::from(v) / 100.0)) - }), - ), - ] - .spacing(10) - .into() - } - - fn rgb_sliders(&self) -> Element<'_, Message> { - let (r, g, b) = self.rgb(); - let ends = |lo: (u8, u8, u8), hi: (u8, u8, u8)| { - vec![ - (0.0, rgb_color(lo.0, lo.1, lo.2)), - (1.0, rgb_color(hi.0, hi.1, hi.2)), - ] - }; - let channel = |value: u8| f32::from(value) / 255.0; - let byte = |v: f32| (v * 255.0).round() as u8; - column![ - slider_row( - "R", - GradientSlider::new(channel(r), ends((0, g, b), (255, g, b)), move |v| { - picker_msg(ColorPickerMsg::Red(byte(v))) - }) - .wheel_step(1.0 / 255.0), - number_box(u16::from(r), 255, None, |v| picker_msg( - ColorPickerMsg::Red(v as u8) - )), - ), - slider_row( - "G", - GradientSlider::new(channel(g), ends((r, 0, b), (r, 255, b)), move |v| { - picker_msg(ColorPickerMsg::Green(byte(v))) - }) - .wheel_step(1.0 / 255.0), - number_box(u16::from(g), 255, None, |v| picker_msg( - ColorPickerMsg::Green(v as u8) - )), - ), - slider_row( - "B", - GradientSlider::new(channel(b), ends((r, g, 0), (r, g, 255)), move |v| { - picker_msg(ColorPickerMsg::Blue(byte(v))) - }) - .wheel_step(1.0 / 255.0), - number_box(u16::from(b), 255, None, |v| picker_msg( - ColorPickerMsg::Blue(v as u8) - )), - ), - ] - .spacing(10) - .into() - } - - fn gradient_editor(&self) -> Element<'_, Message> { - let Hsb { h, s, b } = self.hsb; - let (r, g, bl) = self.rgb(); - let area = ColorArea::new(hsb_color(h, 1.0, 1.0), rgb_color(r, g, bl), s, b, |s, b| { - picker_msg(ColorPickerMsg::SaturationBrightness(s, b)) - }) - .height(Length::Fixed(118.0)); - let hue = GradientSlider::new(h / 360.0, hue_stops(), |v| { - picker_msg(ColorPickerMsg::Hue(v * 360.0)) - }) - .vertical() - .height(Length::Fixed(118.0)) - .wheel_step(1.0 / 360.0); - - let field = |letter: &'static str, input: Element<'static, Message>| { - row![text(letter).size(12).font(iced::Font::MONOSPACE), input] - .spacing(4) - .align_y(iced::Center) - }; - let values = row![ - field( - "H", - number_box(h.round() as u16, 360, Some('°'), |v| picker_msg( - ColorPickerMsg::Hue(f32::from(v)) - )), - ), - Space::new().width(Length::Fill), - field( - "S", - number_box((s * 100.0).round() as u16, 100, Some('%'), |v| { - picker_msg(ColorPickerMsg::Saturation(f32::from(v) / 100.0)) - }), - ), - Space::new().width(Length::Fill), - field( - "B", - number_box((b * 100.0).round() as u16, 100, Some('%'), |v| { - picker_msg(ColorPickerMsg::Brightness(f32::from(v) / 100.0)) - }), - ), - ] - .align_y(iced::Center); - - column![row![area, hue].spacing(12), values] - .spacing(10) - .into() - } - - fn hex_row(&self) -> Element<'_, Message> { - let (r, g, b) = self.rgb(); - let hex = self - .hex_draft - .clone() - .unwrap_or_else(|| math::hex_string(r, g, b)); - let (nearest, exact) = math::nearest_aci_match(r, g, b); - let is_index = matches!(self.pending, AcadColor::Index(_)); - let match_label = if exact && !is_index { - t!("Exact index match") + // The eyedropper reads the window back from the GPU, which the web + // build cannot do. + let pipette: Option> = if cfg!(target_arch = "wasm32") { + None } else { - t!("Nearest index") + Some( + button(crate::ui::icons::themed(crate::ui::icons::EYEDROPPER, 16.0)) + .on_press(picker_msg(ColorPickerMsg::Eyedropper(EyedropperMsg::Start))) + .height(28) + .padding([0, 8]) + .style(|theme: &Theme, status| toggle_style(theme, false, status)) + .into(), + ) }; - let (nr, ng, nb) = math::aci_rgb(nearest).unwrap_or((128, 128, 128)); - let nearest_fill = rgb_color(nr, ng, nb); - let highlight = exact && !is_index; + let nearest = (!self.editor_only()).then(|| self.nearest_button()); + self.editor.view(pipette, nearest) + } + + fn nearest_button(&self) -> Element<'_, Message> { + let (r, g, b) = self.rgb(); + let nearest = math::nearest_aci(r, g, b); + let nearest_rgb = math::aci_rgb(nearest).unwrap_or((128, 128, 128)); + let exact = nearest_rgb == (r, g, b) && !matches!(self.pending, AcadColor::Index(_)); + let fill = rgb_color(nearest_rgb); row![ - text(t!("Hex")).size(11).style(muted_text), - text_input("", &hex) - .on_input(|value| picker_msg(ColorPickerMsg::HexInput(value))) - .on_submit(picker_msg(ColorPickerMsg::HexSubmit)) - .size(12) - .font(iced::Font::MONOSPACE) - .padding([5, 8]) - .width(Length::Fixed(92.0)), - Space::new().width(Length::Fill), - text(match_label).size(11).style(move |theme: &Theme| { - let palette = theme.palette(); - text::Style { - color: Some(if highlight { - palette.success.base.color - } else { - palette.background.base.text.scale_alpha(0.65) - }), - } + text(if exact { + t!("Exact index match") + } else { + t!("Nearest index") + }) + .size(11) + .style(move |theme: &Theme| text::Style { + color: Some(if exact { + theme.palette().success.base.color + } else { + theme.palette().background.base.text.scale_alpha(0.65) + }), }), button( row![ container(Space::new()) .width(16) .height(16) - .style(move |theme: &Theme| swatch_style(theme, nearest_fill)), + .style(move |_theme: &Theme| swatch_style(fill)), text(format!("ACI {nearest}")) .size(12) .font(iced::Font::MONOSPACE), @@ -722,7 +481,7 @@ impl ColorPicker { ) .on_press(picker_msg(ColorPickerMsg::UseNearest)) .padding([4, 8]) - .style(move |theme: &Theme, status| toggle_style(theme, highlight, status)), + .style(move |theme: &Theme, status| toggle_style(theme, exact, status)), ] .spacing(8) .align_y(iced::Center) @@ -734,15 +493,16 @@ impl ColorPicker { /// by family (left/right) and shade (up/down); the standard and gray rows /// move along their row. fn step_index(index: u8, direction: ArrowKey) -> Option { + let along_row = |first: u8, last: u8| match direction { + ArrowKey::Left if index > first => Some(index - 1), + ArrowKey::Right if index < last => Some(index + 1), + _ => None, + }; match index { - 1..=9 => match direction { - ArrowKey::Left if index > 1 => Some(index - 1), - ArrowKey::Right if index < 9 => Some(index + 1), - _ => None, - }, + 1..=9 => along_row(1, 9), + 250..=255 => along_row(250, 255), 10..=249 => { - let family = index / 10; - let shade = index % 10; + let (family, shade) = (index / 10, index % 10); match direction { ArrowKey::Left if family > 1 => Some(index - 10), ArrowKey::Right if family < 24 => Some(index + 10), @@ -751,11 +511,6 @@ fn step_index(index: u8, direction: ArrowKey) -> Option { _ => None, } } - 250..=255 => match direction { - ArrowKey::Left if index > 250 => Some(index - 1), - ArrowKey::Right if index < 255 => Some(index + 1), - _ => None, - }, _ => None, } } @@ -764,89 +519,9 @@ fn picker_msg(msg: ColorPickerMsg) -> Message { Message::ColorPicker(msg) } -fn slider_row<'a>( - letter: &'static str, - slider: GradientSlider<'a, Message>, - number: Element<'a, Message>, -) -> Element<'a, Message> { - row![ - container(text(letter).size(12).font(iced::Font::MONOSPACE)) - .width(12) - .align_x(iced::Center), - slider, - number, - ] - .spacing(8) - .align_y(iced::Center) - .into() -} - -/// A compact spin box: iced_aw's NumberInput keeps its stacked arrows as long -/// as its padding stays at the default. -fn number_box( - value: u16, - max: u16, - unit: Option, - on_change: impl Fn(u16) -> Message + Clone + 'static, -) -> Element<'static, Message> { - let input = iced_aw::widget::NumberInput::new(&value, 0..=max, on_change) - .set_size(12.0) - .font(iced::Font::MONOSPACE) - .width(Length::Fixed(68.0)) - // Extra right padding keeps the digits clear of the arrows; padding - // below 5 would make NumberInput swap its arrows for + / − buttons. - .padding(iced::Padding { - top: 5.0, - right: 18.0, - bottom: 5.0, - left: 6.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), - }, - ); - // The unit is drawn inside the box, after the digits, but is not part - // of the value: the user only ever types the number. - let input = 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, - }), - None => input, - }; - input.into() -} - -fn hue_stops() -> Vec<(f32, Color)> { - (0..=6) - .map(|step| { - let offset = step as f32 / 6.0; - (offset, hsb_color(offset * 360.0, 1.0, 1.0)) - }) - .collect() -} - -fn hsb_color(h: f32, s: f32, b: f32) -> Color { - let (r, g, b) = math::hsb_to_rgb(h, s, b); - rgb_color(r, g, b) -} - -fn rgb_color(r: u8, g: u8, b: u8) -> Color { - Color::from_rgb8(r, g, b) -} - fn ink_is_dark(color: Color) -> bool { - math::ink_is_dark( - (color.r * 255.0) as u8, - (color.g * 255.0) as u8, - (color.b * 255.0) as u8, - ) + let byte = |c: f32| (c * 255.0).round() as u8; + math::ink_is_dark(byte(color.r), byte(color.g), byte(color.b)) } /// Upper-case label for a logical colour shown on a swatch, empty otherwise. @@ -859,15 +534,22 @@ fn logical_badge(color: AcadColor) -> String { } } -fn muted_text(theme: &Theme) -> text::Style { +fn filled(color: Color) -> container::Style { + container::Style { + background: Some(color.into()), + ..Default::default() + } +} + +pub(super) fn muted_text(theme: &Theme) -> text::Style { text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.65)), } } -fn swatch_style(_theme: &Theme, fill: Color) -> container::Style { +fn swatch_style(fill: Color) -> container::Style { container::Style { - background: Some(Background::Color(fill)), + background: Some(fill.into()), border: Border { color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), width: 1.0, @@ -877,14 +559,26 @@ fn swatch_style(_theme: &Theme, fill: Color) -> container::Style { } } +fn panel_style(theme: &Theme) -> container::Style { + let palette = theme.palette(); + container::Style { + background: Some(palette.background.weakest.color.into()), + border: Border { + color: palette.background.weak.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } +} + fn segment_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { let palette = theme.palette(); - let hovered = matches!(status, button::Status::Hovered); button::Style { background: if active { - Some(Background::Color(palette.background.strong.color)) - } else if hovered { - Some(Background::Color(palette.background.weak.color)) + Some(palette.background.strong.color.into()) + } else if matches!(status, button::Status::Hovered) { + Some(palette.background.weak.color.into()) } else { None }, @@ -901,13 +595,12 @@ fn segment_style(theme: &Theme, active: bool, status: button::Status) -> button: } } -fn toggle_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { +pub(super) fn toggle_style(theme: &Theme, active: bool, status: button::Status) -> button::Style { let palette = theme.palette(); - let hovered = matches!(status, button::Status::Hovered); button::Style { background: Some(Background::Color(if active { palette.primary.weak.color - } else if hovered { + } else if matches!(status, button::Status::Hovered) { palette.background.weak.color } else { palette.background.base.color @@ -932,12 +625,17 @@ fn toggle_style(theme: &Theme, active: bool, status: button::Status) -> button:: #[cfg(test)] mod tests { + use super::super::editor::EditorMode; use super::*; fn picker(current: AcadColor) -> ColorPicker { ColorPicker::new(ColorPickTarget::Ribbon, current, false) } + fn edit(p: &mut ColorPicker, msg: EditorMsg) { + p.update(ColorPickerMsg::Editor(msg)); + } + #[test] fn opens_on_the_tab_that_fits_the_current_colour() { assert_eq!(picker(AcadColor::Index(30)).tab(), ColorPickerTab::Index); @@ -980,6 +678,16 @@ mod tests { b: 252 })) ); + let mut p = picker(AcadColor::Index(30)); + edit(&mut p, EditorMsg::HexInput("#3bf".into())); + assert_eq!( + p.update(ColorPickerMsg::Ok), + Some(Outcome::Commit(AcadColor::Rgb { + r: 0x33, + g: 0xBB, + b: 0xFF + })) + ); } #[test] @@ -991,42 +699,12 @@ mod tests { } #[test] - fn sliders_produce_true_colours() { + fn the_editor_produces_true_colours() { let mut p = picker(AcadColor::Index(1)); - p.update(ColorPickerMsg::Hue(120.0)); + edit(&mut p, EditorMsg::Hue(120.0)); + assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 255, b: 0 }); + edit(&mut p, EditorMsg::Mode(EditorMode::Rgb)); assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 255, b: 0 }); - p.update(ColorPickerMsg::Red(10)); - assert_eq!( - p.pending(), - AcadColor::Rgb { - r: 10, - g: 255, - b: 0 - } - ); - } - - #[test] - fn hue_survives_a_trip_through_gray() { - let mut p = picker(AcadColor::Rgb { r: 0, g: 0, b: 255 }); - p.update(ColorPickerMsg::Saturation(0.0)); - p.update(ColorPickerMsg::Saturation(1.0)); - assert_eq!(p.pending(), AcadColor::Rgb { r: 0, g: 0, b: 255 }); - } - - #[test] - fn hex_field_sets_a_true_colour() { - let mut p = picker(AcadColor::Index(1)); - p.update(ColorPickerMsg::HexInput("#3bf".into())); - p.update(ColorPickerMsg::HexSubmit); - assert_eq!( - p.pending(), - AcadColor::Rgb { - r: 0x33, - g: 0xBB, - b: 0xFF - } - ); } #[test] @@ -1051,6 +729,39 @@ mod tests { assert_eq!(p.pending(), AcadColor::Index(150)); } + #[test] + fn eyedropper_picks_a_pixel_and_esc_only_leaves_it() { + let mut p = picker(AcadColor::Index(30)); + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Start)); + assert_eq!( + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Move( + iced::Point::ORIGIN + ))), + Some(Outcome::Screenshot) + ); + // Esc and Enter while picking never close the panel. + assert_eq!(p.update(ColorPickerMsg::Ok), None); + assert_eq!(p.update(ColorPickerMsg::Cancel), None); + assert!(p.eyedropper_view().is_none()); + + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Start)); + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Move( + iced::Point::ORIGIN, + ))); + let shot = iced::window::Screenshot::new(vec![10, 20, 30, 255], iced::Size::new(1, 1), 1.0); + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Shot(shot))); + p.update(ColorPickerMsg::Eyedropper(EyedropperMsg::Pick)); + assert!(p.eyedropper_view().is_none()); + assert_eq!( + p.pending(), + AcadColor::Rgb { + r: 10, + g: 20, + b: 30 + } + ); + } + // ── Headless UI tests ─────────────────────────────────────────────── // // These run the real widgets through iced's simulator. Setting @@ -1093,75 +804,108 @@ mod tests { g: 191, b: 252, }); - p.update(ColorPickerMsg::Mode(TrueColorMode::Rgb)); + edit(&mut p, EditorMsg::Mode(EditorMode::Rgb)); let mut ui = simulator(&p); - // The first slider row sits below the tabs, preview row and mode - // buttons; press near its left end. - let red_row = ui.find("R").expect("R label"); - let bounds = red_row.visible_bounds().expect("R visible"); + let label = ui.find("R").expect("R label"); + let bounds = label.visible_bounds().expect("R visible"); ui.point_at(iced::Point::new(bounds.x + 40.0, bounds.center_y())); ui.simulate(iced_test::simulator::click()); assert!(published(ui) .iter() - .any(|msg| matches!(msg, ColorPickerMsg::Red(v) if *v < 57))); + .any(|msg| matches!(msg, ColorPickerMsg::Editor(EditorMsg::Red(v)) if *v < 57))); + } + + #[test] + fn interface_colours_show_only_the_editor() { + let p = ColorPicker::new( + ColorPickTarget::Background(crate::app::BgTarget::Model), + AcadColor::Rgb { + r: 30, + g: 30, + b: 30, + }, + false, + ); + let mut ui = simulator(&p); + assert!(ui.find(t!("Index Color").as_ref()).is_err()); + assert!(ui.find(t!("Nearest index").as_ref()).is_err()); + assert!(ui.find("HSB").is_ok()); } #[test] fn every_view_renders() { let folder = std::env::var_os("OCS_COLOR_PICKER_SNAPSHOTS").map(std::path::PathBuf::from); - let views: [(&str, AcadColor, ColorPickerTab, TrueColorMode, bool); 6] = [ + let blue = AcadColor::Rgb { + r: 57, + g: 191, + b: 252, + }; + let views: [( + &str, + ColorPickTarget, + AcadColor, + ColorPickerTab, + EditorMode, + bool, + ); 7] = [ ( "index", + ColorPickTarget::Properties, AcadColor::Index(140), ColorPickerTab::Index, - TrueColorMode::Hsb, + EditorMode::Hsb, false, ), ( "varies", + ColorPickTarget::Properties, AcadColor::Index(30), ColorPickerTab::Index, - TrueColorMode::Hsb, + EditorMode::Hsb, true, ), ( "hsb", - AcadColor::Rgb { - r: 57, - g: 191, - b: 252, - }, + ColorPickTarget::Properties, + blue, ColorPickerTab::TrueColor, - TrueColorMode::Hsb, + EditorMode::Hsb, false, ), ( "rgb", - AcadColor::Rgb { - r: 57, - g: 191, - b: 252, - }, + ColorPickTarget::Properties, + blue, ColorPickerTab::TrueColor, - TrueColorMode::Rgb, + EditorMode::Rgb, false, ), ( - "gradient", - AcadColor::Rgb { - r: 57, - g: 191, - b: 252, - }, + "field", + ColorPickTarget::Properties, + blue, ColorPickerTab::TrueColor, - TrueColorMode::Gradient, + EditorMode::Field, false, ), ( "exact", + ColorPickTarget::Properties, AcadColor::Rgb { r: 255, g: 0, b: 0 }, ColorPickerTab::TrueColor, - TrueColorMode::Hsb, + EditorMode::Hsb, + false, + ), + ( + "interface", + ColorPickTarget::Background(crate::app::BgTarget::Model), + AcadColor::Rgb { + r: 33, + g: 40, + b: 48, + }, + ColorPickerTab::TrueColor, + EditorMode::Hsb, false, ), ]; @@ -1169,10 +913,10 @@ mod tests { crate::ui::style::fusion_theme::fusion_black(), crate::ui::style::fusion_theme::fusion_white(), ] { - for (name, color, tab, mode, varies) in views { - let mut p = ColorPicker::new(ColorPickTarget::Properties, color, varies); + for (name, target, color, tab, mode, varies) in views.clone() { + let mut p = ColorPicker::new(target, color, varies); p.update(ColorPickerMsg::Tab(tab)); - p.update(ColorPickerMsg::Mode(mode)); + edit(&mut p, EditorMsg::Mode(mode)); let mut ui = simulator(&p); let snapshot = ui.snapshot(&theme).expect("snapshot"); if let Some(folder) = &folder { diff --git a/src/ui/color_select/widgets/gradient_slider.rs b/src/ui/color_select/widgets/gradient_slider.rs deleted file mode 100644 index 36893e9e3..000000000 --- a/src/ui/color_select/widgets/gradient_slider.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! A slider whose track is a colour gradient, with a white bar handle. -//! -//! Used for the HSB and RGB channels of the Select Color panel and, upright, -//! for the hue strip beside the saturation/brightness field. The value is -//! normalised to 0–1; callers map it to their own range. - -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, Shadow, Size, Theme, Vector, -}; - -const TRACK_THICKNESS: f32 = 18.0; -const TRACK_RADIUS: f32 = 5.0; -const HANDLE_THICKNESS: f32 = 8.0; -const HANDLE_RADIUS: f32 = 3.0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Orientation { - Horizontal, - Vertical, -} - -pub struct GradientSlider<'a, Message> { - value: f32, - stops: Vec<(f32, Color)>, - on_change: Box Message + 'a>, - orientation: Orientation, - /// How far one mouse-wheel notch moves the value (0–1 units). - wheel_step: f32, - width: Length, - height: Length, -} - -#[derive(Default)] -struct State { - dragging: bool, -} - -impl<'a, Message> GradientSlider<'a, Message> { - /// `value` is 0–1. `stops` are (offset 0–1, colour) pairs, at most eight. - pub fn new( - value: f32, - stops: Vec<(f32, Color)>, - on_change: impl Fn(f32) -> Message + 'a, - ) -> Self { - Self { - value: value.clamp(0.0, 1.0), - stops, - on_change: Box::new(on_change), - orientation: Orientation::Horizontal, - wheel_step: 0.01, - width: Length::Fill, - height: Length::Fixed(26.0), - } - } - - pub fn vertical(mut self) -> Self { - self.orientation = Orientation::Vertical; - self.width = Length::Fixed(22.0); - self.height = Length::Fill; - self - } - - pub fn wheel_step(mut self, step: f32) -> Self { - self.wheel_step = step; - self - } - - pub fn width(mut self, width: impl Into) -> Self { - self.width = width.into(); - self - } - - pub fn height(mut self, height: impl Into) -> Self { - self.height = height.into(); - self - } - - /// The span the handle centre travels along, in the slider's main axis. - fn travel(&self, bounds: Rectangle) -> (f32, f32) { - let inset = HANDLE_THICKNESS / 2.0; - match self.orientation { - Orientation::Horizontal => (bounds.x + inset, bounds.x + bounds.width - inset), - Orientation::Vertical => (bounds.y + inset, bounds.y + bounds.height - inset), - } - } - - fn value_at(&self, bounds: Rectangle, cursor: Point) -> f32 { - let (start, end) = self.travel(bounds); - let along = match self.orientation { - Orientation::Horizontal => cursor.x, - Orientation::Vertical => cursor.y, - }; - if end <= start { - return 0.0; - } - ((along - start) / (end - start)).clamp(0.0, 1.0) - } - - fn track_bounds(&self, bounds: Rectangle) -> Rectangle { - match self.orientation { - Orientation::Horizontal => { - let thickness = TRACK_THICKNESS.min(bounds.height); - Rectangle { - y: bounds.y + (bounds.height - thickness) / 2.0, - height: thickness, - ..bounds - } - } - Orientation::Vertical => bounds, - } - } - - fn gradient(&self) -> Background { - // Gradient angles run clockwise from "bottom to top": a quarter turn - // is left to right, a half turn top to bottom. - let angle = match self.orientation { - Orientation::Horizontal => std::f32::consts::FRAC_PI_2, - Orientation::Vertical => std::f32::consts::PI, - }; - let linear = self - .stops - .iter() - .fold(Linear::new(Radians(angle)), |linear, &(offset, color)| { - linear.add_stop(offset, color) - }); - Background::Gradient(Gradient::Linear(linear)) - } -} - -impl Widget for GradientSlider<'_, Message> { - fn tag(&self) -> tree::Tag { - tree::Tag::of::() - } - - fn state(&self) -> tree::State { - tree::State::new(State::default()) - } - - fn size(&self) -> Size { - 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::(); - let bounds = layout.bounds(); - match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { - if let Some(position) = cursor.position_over(bounds) { - state.dragging = true; - shell.publish((self.on_change)(self.value_at(bounds, position))); - shell.capture_event(); - } - } - Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { - if let Some(position) = cursor.position() { - shell.publish((self.on_change)(self.value_at(bounds, position))); - shell.capture_event(); - } - } - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => { - state.dragging = false; - shell.capture_event(); - } - Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => { - let lines = match delta { - mouse::ScrollDelta::Lines { y, .. } => *y, - mouse::ScrollDelta::Pixels { y, .. } => y.signum(), - }; - if lines != 0.0 { - // Scrolling up raises the value on a horizontal slider and - // moves the handle up on an upright one. - let direction = match self.orientation { - Orientation::Horizontal => lines.signum(), - Orientation::Vertical => -lines.signum(), - }; - let next = (self.value + direction * self.wheel_step).clamp(0.0, 1.0); - shell.publish((self.on_change)(next)); - 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::().dragging { - mouse::Interaction::Grabbing - } else if cursor.is_over(layout.bounds()) { - mouse::Interaction::Pointer - } 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(); - renderer.fill_quad( - renderer::Quad { - bounds: self.track_bounds(bounds), - border: Border { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), - width: 1.0, - radius: TRACK_RADIUS.into(), - }, - ..renderer::Quad::default() - }, - self.gradient(), - ); - - let (start, end) = self.travel(bounds); - let centre = start + (end - start) * self.value; - let handle = match self.orientation { - Orientation::Horizontal => Rectangle { - x: centre - HANDLE_THICKNESS / 2.0, - y: bounds.y, - width: HANDLE_THICKNESS, - height: bounds.height, - }, - Orientation::Vertical => Rectangle { - x: bounds.x - 3.0, - y: centre - HANDLE_THICKNESS / 2.0, - width: bounds.width + 6.0, - height: HANDLE_THICKNESS, - }, - }; - renderer.fill_quad( - renderer::Quad { - bounds: handle, - border: Border { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), - width: 1.0, - radius: HANDLE_RADIUS.into(), - }, - shadow: Shadow { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.55), - offset: Vector::new(0.0, 1.0), - blur_radius: 4.0, - }, - ..renderer::Quad::default() - }, - Color::WHITE, - ); - } -} - -impl<'a, Message: 'a> From> for Element<'a, Message> { - fn from(slider: GradientSlider<'a, Message>) -> Self { - Element::new(slider) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn slider() -> GradientSlider<'static, f32> { - GradientSlider::new(0.5, vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], |v| v) - } - - #[test] - fn cursor_maps_to_value_inside_handle_travel() { - let bounds = Rectangle::new(Point::ORIGIN, Size::new(108.0, 26.0)); - let s = slider(); - assert_eq!(s.value_at(bounds, Point::new(4.0, 10.0)), 0.0); - assert_eq!(s.value_at(bounds, Point::new(104.0, 10.0)), 1.0); - assert!((s.value_at(bounds, Point::new(54.0, 10.0)) - 0.5).abs() < 1e-6); - assert_eq!(s.value_at(bounds, Point::new(-50.0, 10.0)), 0.0); - assert_eq!(s.value_at(bounds, Point::new(500.0, 10.0)), 1.0); - } - - #[test] - fn vertical_slider_reads_the_y_axis() { - let bounds = Rectangle::new(Point::ORIGIN, Size::new(22.0, 108.0)); - let s = slider().vertical(); - assert_eq!(s.value_at(bounds, Point::new(11.0, 4.0)), 0.0); - assert_eq!(s.value_at(bounds, Point::new(11.0, 104.0)), 1.0); - } -} diff --git a/src/ui/color_select/widgets/mod.rs b/src/ui/color_select/widgets/mod.rs index b11df1c54..f25fc5ffa 100644 --- a/src/ui/color_select/widgets/mod.rs +++ b/src/ui/color_select/widgets/mod.rs @@ -1,7 +1,5 @@ //! Widgets the Select Color panel needs that iced does not provide. mod color_area; -mod gradient_slider; pub use color_area::ColorArea; -pub use gradient_slider::GradientSlider; diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 694b98a07..f995c252b 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -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"); diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index a6ff74874..99d116c9d 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -157,22 +157,18 @@ impl fmt::Display for Labelled { } } -/// A background swatch that opens the colour wheel when clicked. +/// A background swatch that opens the colour picker when clicked. /// -/// The wheel sits alongside the hex field rather than replacing it: typing +/// The picker sits alongside 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, -) -> Element<'a, Message> { - let colour = iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]); +/// picker is for choosing one by eye. +fn bg_swatch_picker<'a>(target: crate::app::BgTarget, rgb: [u8; 3]) -> Element<'a, Message> { + let [r, g, b] = rgb; 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, @@ -180,18 +176,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::Background(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)] @@ -227,7 +219,6 @@ pub fn view_window<'a>( model_bg_input: &'a str, paper_bg_input: &'a str, desk_bg_input: &'a str, - bg_picker: Option, dirty: bool, close_confirm: bool, sizing: crate::ui::modal::ModalSizing, @@ -742,7 +733,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), + bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb), text_input("#RRGGBB", model_bg_input) .on_input(Message::ModelSpaceBgChanged) .width(150), @@ -755,7 +746,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), + bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb), text_input("#RRGGBB", paper_bg_input) .on_input(Message::PaperSpaceBgChanged) .width(150), @@ -767,7 +758,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), + bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb), text_input("#RRGGBB", desk_bg_input) .on_input(Message::DeskSpaceBgChanged) .width(150),