From 78916d3ae7de520423e6f549b82dde5edd06a31c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:24:02 +0000 Subject: [PATCH] Select Color: full-width Current/New band, Tab focus, tighter fields - Current and New are a 50/50 band across the panel with their names and values inside; the Color text box is gone. - The gradient mode button is one gradient across the whole button, gray until that mode is active. Mode buttons share one width. - Tab and Shift+Tab move between the panel's own fields. - Number boxes are 54 px wide, the hex box 74 px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8 --- src/app/update/mod.rs | 5 ++ src/ui/color_select/editor.rs | 40 +++++---- src/ui/color_select/math.rs | 117 ++++--------------------- src/ui/color_select/picker.rs | 157 ++++++++++------------------------ 4 files changed, 90 insertions(+), 229 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 14af0ca72..eddd93911 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -397,6 +397,11 @@ impl OpenCADStudio { Some("ENTER" | "RETURN") => { return self.update(Message::ColorPicker(ColorPickerMsg::Ok)); } + Some("TAB") => { + return crate::ui::color_select::picker::focus_step( + !key.contains("SHIFT+"), + ); + } _ => {} }, Message::ArrowKeyPressed { direction, .. } => { diff --git a/src/ui/color_select/editor.rs b/src/ui/color_select/editor.rs index 770fbfcc6..d3c3b1df2 100644 --- a/src/ui/color_select/editor.rs +++ b/src/ui/color_select/editor.rs @@ -145,33 +145,37 @@ impl ColorEditor { let Hsb { h, s, b } = self.hsb; let (r, g, bl) = self.rgb; - let mode_button = |content: Element<'a, Message>, mode: EditorMode| { + let mode_button = |content: Element<'a, Message>, mode: EditorMode, padding: u16| { let active = self.mode == mode; - button(container(content).center_y(Length::Fill)) + button(container(content).center(Length::Fill)) .on_press(msg(EditorMsg::Mode(mode))) + .width(52) .height(28) - .padding([0, 10]) + .padding(padding) .style(move |theme: &Theme, status| { super::picker::toggle_style(theme, active, status) }) }; + // The field button is all gradient; it only takes colour when active. + let field_active = self.mode == EditorMode::Field; let field_icon = container(Space::new()) - .width(26) - .height(16) - .style(|_theme: &Theme| { + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme: &Theme| { + let middle = if field_active { + Color::from_rgb8(0x3A, 0xA0, 0xFF) + } else { + Color::from_rgb8(0x80, 0x80, 0x80) + }; 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), - ], + &[(0.0, Color::WHITE), (0.5, middle), (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), + mode_button(text("HSB").size(12).into(), EditorMode::Hsb, 0), + mode_button(text("RGB").size(12).into(), EditorMode::Rgb, 0), + mode_button(field_icon.into(), EditorMode::Field, 3), Space::new().width(Length::Fill), ] .spacing(6); @@ -291,8 +295,8 @@ impl ColorEditor { .on_submit(msg(EditorMsg::HexSubmit)) .size(12) .font(iced::Font::MONOSPACE) - .padding([5, 8]) - .width(Length::Fixed(92.0)), + .padding([5, 6]) + .width(Length::Fixed(74.0)), Space::new().width(Length::Fill), ] .spacing(8) @@ -359,14 +363,14 @@ fn number_box( }) .set_size(12.0) .font(iced::Font::MONOSPACE) - .width(Length::Fixed(68.0)) + .width(Length::Fixed(54.0)) // Padding under 5 would make NumberInput swap its arrows for + / −; // the extra right padding keeps the digits clear of the arrows. .padding(iced::Padding { top: 5.0, right: 18.0, bottom: 5.0, - left: 6.0, + left: 2.0, }) .align_x(iced::alignment::Horizontal::Right) .style( diff --git a/src/ui/color_select/math.rs b/src/ui/color_select/math.rs index 4a01eff3a..4180d36d3 100644 --- a/src/ui/color_select/math.rs +++ b/src/ui/color_select/math.rs @@ -98,56 +98,11 @@ pub fn parse_hex(text: &str) -> Option<(u8, u8, u8)> { Some(((value >> 16) as u8, (value >> 8) as u8, value as u8)) } -/// Parse what the user typed in the Color field. -/// -/// Accepts an index (`140`, `aci 140`), a standard colour name (`red`), an -/// `r,g,b` triple, or hex (`#39BFFC`). Logical colours are not accepted: the -/// panel has no ByLayer / ByBlock entry. -pub fn parse_color_input(text: &str) -> Option { - let text = text.trim().to_ascii_lowercase(); - if text.is_empty() { - return None; - } - let named = match text.as_str() { - "red" => Some(1), - "yellow" => Some(2), - "green" => Some(3), - "cyan" => Some(4), - "blue" => Some(5), - "magenta" => Some(6), - "white" | "black" => Some(7), - _ => None, - }; - if let Some(index) = named { - return Some(AcadColor::Index(index)); - } - let number = text.strip_prefix("aci").map(str::trim).unwrap_or(&text); - if !number.is_empty() && number.chars().all(|c| c.is_ascii_digit()) { - return match number.parse::() { - Ok(n @ 1..=255) => Some(AcadColor::Index(n as u8)), - _ => None, - }; - } - if text.contains(',') { - let parts: Vec<&str> = text.split(',').map(str::trim).collect(); - if let [r, g, b] = parts.as_slice() { - let (r, g, b) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?); - return Some(AcadColor::Rgb { r, g, b }); - } - return None; - } - if text.starts_with('#') || text.len() == 6 { - let (r, g, b) = parse_hex(&text)?; - return Some(AcadColor::Rgb { r, g, b }); - } - None -} - -/// The text the Color field shows for a colour. -pub fn color_field_text(color: AcadColor) -> String { +/// The short name shown on the Current / New swatch. +pub fn short_name(color: AcadColor) -> String { match color { - AcadColor::Index(i) => i.to_string(), - AcadColor::Rgb { r, g, b } => format!("{r}, {g}, {b}"), + AcadColor::Index(i) => format!("ACI {i}"), + AcadColor::Rgb { r, g, b } => hex_string(r, g, b), AcadColor::ByLayer => "ByLayer".to_string(), AcadColor::ByBlock => "ByBlock".to_string(), AcadColor::None => "None".to_string(), @@ -162,7 +117,7 @@ pub fn describe(color: AcadColor) -> String { format!("ACI {i} · RGB {r}, {g}, {b}") } AcadColor::Rgb { r, g, b } => format!("RGB {r}, {g}, {b} · {}", hex_string(r, g, b)), - other => color_field_text(other), + other => short_name(other), } } @@ -212,6 +167,20 @@ mod tests { assert_eq!(nearest_aci(250, 5, 5), 1); } + #[test] + fn short_names() { + assert_eq!(short_name(AcadColor::Index(140)), "ACI 140"); + assert_eq!( + short_name(AcadColor::Rgb { + r: 57, + g: 191, + b: 252 + }), + "#39BFFC" + ); + assert_eq!(short_name(AcadColor::ByLayer), "ByLayer"); + } + #[test] fn hex_formats_and_parses() { assert_eq!(hex_string(57, 191, 252), "#39BFFC"); @@ -221,52 +190,4 @@ mod tests { assert_eq!(parse_hex("#39BFF"), None); assert_eq!(parse_hex("zzzzzz"), None); } - - #[test] - fn colour_field_accepts_index_name_rgb_and_hex() { - assert_eq!(parse_color_input("140"), Some(AcadColor::Index(140))); - assert_eq!(parse_color_input(" ACI 7 "), Some(AcadColor::Index(7))); - assert_eq!(parse_color_input("Red"), Some(AcadColor::Index(1))); - assert_eq!( - parse_color_input("57, 191,252"), - Some(AcadColor::Rgb { - r: 57, - g: 191, - b: 252 - }) - ); - assert_eq!( - parse_color_input("#39BFFC"), - Some(AcadColor::Rgb { - r: 57, - g: 191, - b: 252 - }) - ); - } - - #[test] - fn colour_field_rejects_logical_and_out_of_range_values() { - assert_eq!(parse_color_input("bylayer"), None); - assert_eq!(parse_color_input("byblock"), None); - assert_eq!(parse_color_input("0"), None); - assert_eq!(parse_color_input("256"), None); - assert_eq!(parse_color_input("300,0,0"), None); - assert_eq!(parse_color_input("1,2"), None); - assert_eq!(parse_color_input(""), None); - } - - #[test] - fn field_text_matches_parser() { - for color in [ - AcadColor::Index(140), - AcadColor::Rgb { - r: 57, - g: 191, - b: 252, - }, - ] { - assert_eq!(parse_color_input(&color_field_text(color)), Some(color)); - } - } } diff --git a/src/ui/color_select/picker.rs b/src/ui/color_select/picker.rs index bc13a55bf..f620c8845 100644 --- a/src/ui/color_select/picker.rs +++ b/src/ui/color_select/picker.rs @@ -12,14 +12,16 @@ use crate::app::{ArrowKey, ColorPickTarget, Message}; use crate::t; use crate::ui::properties::acad_color_display; use codec::types::Color as AcadColor; -use iced::widget::{button, column, container, row, text, text_input, Space}; +use iced::widget::{button, column, container, row, text, Space}; use iced::{Background, Border, Color, Element, Length, Theme}; const PANEL_WIDTH: f32 = 400.0; +/// Scope for Tab / Shift+Tab, so focus cycles through the panel's own +/// fields instead of the dialogs and command line underneath. +const PANEL_ID: &str = "select-color-panel"; const BODY_HEIGHT: f32 = 352.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)] pub enum ColorPickerTab { @@ -36,8 +38,6 @@ pub enum ColorPickerMsg { /// Set the new colour back to the current one. Revert, Editor(EditorMsg), - FieldInput(String), - FieldSubmit, /// Replace the true colour with its nearest index colour. UseNearest, Arrow(ArrowKey), @@ -64,7 +64,6 @@ pub struct ColorPicker { pending: AcadColor, tab: ColorPickerTab, editor: ColorEditor, - field_draft: Option, eyedropper: Option, } @@ -82,7 +81,6 @@ impl ColorPicker { pending: current, tab, editor: ColorEditor::new(math::color_rgb(current).unwrap_or((255, 255, 255))), - field_draft: None, eyedropper: None, } } @@ -102,7 +100,6 @@ impl ColorPicker { fn set_pending(&mut self, color: AcadColor) { self.pending = color; - self.field_draft = None; if let Some(rgb) = math::color_rgb(color) { self.editor.set_rgb(rgb); } @@ -142,17 +139,6 @@ impl ColorPicker { 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(color) = self - .field_draft - .take() - .and_then(|d| math::parse_color_input(&d)) - { - self.set_pending(color); } } ColorPickerMsg::UseNearest => { @@ -172,7 +158,6 @@ impl ColorPicker { ColorPickerMsg::Eyedropper(_) => {} ColorPickerMsg::Ok => { // Apply a value still being typed, as if Enter had been pressed. - self.update(ColorPickerMsg::FieldSubmit); self.update(ColorPickerMsg::Editor(EditorMsg::HexSubmit)); return Some(Outcome::Commit(self.pending)); } @@ -246,10 +231,8 @@ impl ColorPicker { if !editor_only { panel = panel.push(self.tabs()); } - panel - .push(self.preview_row()) - .push(body) - .push(actions) + container(panel.push(self.preview_row()).push(body).push(actions)) + .id(PANEL_ID) .into() } @@ -274,33 +257,32 @@ impl ColorPicker { .into() } + /// Current and New side by side across the full width, the divider + /// under the middle of the tab bar. Clicking Current reverts. fn preview_row(&self) -> Element<'_, Message> { - let caption = |label: String| text(label).size(11).style(muted_text); - let swatch_text = |label: String, background: Color| { - let ink = if ink_is_dark(background) { + let half = |label: String, value: String, fill: Color| { + let ink = if ink_is_dark(fill) { Color::from_rgb8(0x11, 0x12, 0x14) } else { Color::WHITE }; - container( - text(label) - .size(9) - .font(iced::Font { - weight: iced::font::Weight::Bold, - ..iced::Font::DEFAULT - }) - .color(ink), - ) - .center(Length::Fill) + column![ + text(label).size(11).color(ink.scale_alpha(0.85)), + Space::new().height(Length::Fill), + text(value).size(11).font(iced::Font::MONOSPACE).color(ink), + ] + .padding([8, 10]) + .width(Length::Fill) + .height(Length::Fill) }; let (current_bg, _) = acad_color_display(self.current); let (new_bg, _) = acad_color_display(self.pending); - let current_label = if self.varies { + let current_value = if self.varies { "*VARIES*".to_string() } else { - logical_badge(self.current) + math::short_name(self.current) }; - let current = button(swatch_text(current_label, current_bg)) + let current = button(half(t!("Current").into_owned(), current_value, current_bg)) .on_press(picker_msg(ColorPickerMsg::Revert)) .padding(0) .width(Length::Fill) @@ -309,60 +291,26 @@ impl ColorPicker { background: Some(current_bg.into()), ..Default::default() }); - let new = container(swatch_text(logical_badge(self.pending), new_bg)) + let new = container(half( + t!("New").into_owned(), + math::short_name(self.pending), + new_bg, + )) + .style(move |_theme: &Theme| filled(new_bg)); + container(row![current, new]) .width(Length::Fill) - .height(Length::Fill) - .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(28) + .height(60) .padding(1) .style(|theme: &Theme| container::Style { border: Border { color: theme.palette().background.strong.color, width: 1.0, - radius: 6.0.into(), + radius: 8.0.into(), }, ..Default::default() }) - .clip(true); - - let mut preview = row![column![ - row![ - caption(t!("Current").into_owned()), - Space::new().width(Length::Fill), - caption(t!("New").into_owned()), - ] - .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), - ); - } - preview.into() + .clip(true) + .into() } fn index_tab(&self) -> Element<'_, Message> { @@ -515,6 +463,17 @@ fn step_index(index: u8, direction: ArrowKey) -> Option { } } +/// Move keyboard focus to the next (or previous) field inside the panel. +pub fn focus_step(forward: bool) -> iced::Task { + use iced::advanced::widget::operation::{focusable, scope}; + let id = iced::widget::Id::new(PANEL_ID); + iced_runtime::task::effect(if forward { + iced_runtime::Action::widget(scope(id, focusable::focus_next())) + } else { + iced_runtime::Action::widget(scope(id, focusable::focus_previous())) + }) +} + fn picker_msg(msg: ColorPickerMsg) -> Message { Message::ColorPicker(msg) } @@ -524,16 +483,6 @@ fn ink_is_dark(color: Color) -> bool { 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. -fn logical_badge(color: AcadColor) -> String { - match color { - AcadColor::ByLayer => "BYLAYER".to_string(), - AcadColor::ByBlock => "BYBLOCK".to_string(), - AcadColor::None => "NONE".to_string(), - _ => String::new(), - } -} - fn filled(color: Color) -> container::Style { container::Style { background: Some(color.into()), @@ -667,17 +616,7 @@ mod tests { } #[test] - fn ok_applies_text_still_being_typed() { - let mut p = picker(AcadColor::Index(30)); - p.update(ColorPickerMsg::FieldInput("57,191,252".into())); - assert_eq!( - p.update(ColorPickerMsg::Ok), - Some(Outcome::Commit(AcadColor::Rgb { - r: 57, - g: 191, - b: 252 - })) - ); + fn ok_applies_a_hex_value_still_being_typed() { let mut p = picker(AcadColor::Index(30)); edit(&mut p, EditorMsg::HexInput("#3bf".into())); assert_eq!( @@ -690,14 +629,6 @@ mod tests { ); } - #[test] - fn invalid_field_text_keeps_the_colour() { - let mut p = picker(AcadColor::Index(30)); - p.update(ColorPickerMsg::FieldInput("bylayer".into())); - p.update(ColorPickerMsg::FieldSubmit); - assert_eq!(p.pending(), AcadColor::Index(30)); - } - #[test] fn the_editor_produces_true_colours() { let mut p = picker(AcadColor::Index(1));