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),