forked from 1b-agent/opencadstudio-upstream
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:
4 files changed
+90
-229
No files matched your search
@@ -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, .. } => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user