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 <[email protected]>
Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
Claude committed 2026-10-12 18:24:02 +00:00
1 parent cefa258823
commit 78916d3ae7
4 files changed
+90 -229

No files matched your search

+5
View File
@@ -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, .. } => {
+22 -18
View File
@@ -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(
+19 -98
View File
@@ -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<AcadColor> {
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::<u16>() {
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));
}
}
}
+44 -113
View File
@@ -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<String>,
eyedropper: Option<Eyedropper>,
}
@@ -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<u8> {
}
}
/// Move keyboard focus to the next (or previous) field inside the panel.
pub fn focus_step<T>(forward: bool) -> iced::Task<T> {
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));