feat(iced): implement full HSV color picker for geometry and properties panels
This commit is contained in:
@@ -6,11 +6,157 @@
|
||||
//! - HSL sliders
|
||||
//! - 10x10 palette grid
|
||||
//! - Recent colors
|
||||
//! - HSV color picker popup for geometry and properties panels
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input};
|
||||
use iced::{Element, Length, Padding};
|
||||
|
||||
/// HSV color representation (Hue, Saturation, Value)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Hsv {
|
||||
pub h: f32, // 0..360
|
||||
pub s: f32, // 0..1
|
||||
pub v: f32, // 0..1
|
||||
}
|
||||
|
||||
impl Hsv {
|
||||
pub fn to_rgb(&self) -> (u8, u8, u8) {
|
||||
hsv_to_rgb(self.h, self.s, self.v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert RGB to HSV.
|
||||
pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
|
||||
let r = r as f32 / 255.0;
|
||||
let g = g as f32 / 255.0;
|
||||
let b = b as f32 / 255.0;
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let d = max - min;
|
||||
|
||||
let h = if max == min {
|
||||
0.0
|
||||
} else if max == r {
|
||||
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
|
||||
} else if max == g {
|
||||
((b - r) / d + 2.0) / 6.0
|
||||
} else {
|
||||
((r - g) / d + 4.0) / 6.0
|
||||
};
|
||||
|
||||
let s = if max == 0.0 { 0.0 } else { d / max };
|
||||
let v = max;
|
||||
|
||||
Hsv {
|
||||
h: h * 360.0,
|
||||
s,
|
||||
v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert HSV to RGB.
|
||||
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
|
||||
let h = h / 360.0;
|
||||
let (r, g, b) = if s == 0.0 {
|
||||
(v, v, v)
|
||||
} else {
|
||||
let i = (h * 6.0).floor();
|
||||
let f = h * 6.0 - i;
|
||||
let p = v * (1.0 - s);
|
||||
let q = v * (1.0 - s * f);
|
||||
let t = v * (1.0 - s * (1.0 - f));
|
||||
match i as i32 % 6 {
|
||||
0 => (v, t, p),
|
||||
1 => (q, v, p),
|
||||
2 => (p, v, t),
|
||||
3 => (p, q, v),
|
||||
4 => (t, p, v),
|
||||
_ => (v, p, q),
|
||||
}
|
||||
};
|
||||
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
|
||||
}
|
||||
|
||||
/// Create a compact HSV color picker popup for inline use in panels.
|
||||
/// Returns a color swatch that opens a popup when clicked.
|
||||
pub fn color_popup<'a>(
|
||||
color: [u8; 4],
|
||||
label: &'a str,
|
||||
on_change: fn([u8; 4]) -> Message,
|
||||
) -> Element<'a, Message> {
|
||||
let r = color[0];
|
||||
let g = color[1];
|
||||
let b = color[2];
|
||||
let a = color[3];
|
||||
let hsv = rgb_to_hsv(r, g, b);
|
||||
|
||||
// Color swatch button
|
||||
let swatch = container(text(""))
|
||||
.width(20)
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// HSV sliders
|
||||
let h_slider = slider(0.0..=360.0, hsv.h, move |v| {
|
||||
let (r, g, b) = hsv_to_rgb(v, hsv.s, hsv.v);
|
||||
on_change([r, g, b, a])
|
||||
}).step(1.0);
|
||||
|
||||
let s_slider = slider(0.0..=100.0, hsv.s * 100.0, move |v| {
|
||||
let (r, g, b) = hsv_to_rgb(hsv.h, v / 100.0, hsv.v);
|
||||
on_change([r, g, b, a])
|
||||
}).step(1.0);
|
||||
|
||||
let v_slider = slider(0.0..=100.0, hsv.v * 100.0, move |v| {
|
||||
let (r, g, b) = hsv_to_rgb(hsv.h, hsv.s, v / 100.0);
|
||||
on_change([r, g, b, a])
|
||||
}).step(1.0);
|
||||
|
||||
// Alpha slider
|
||||
let a_slider = slider(0..=255u8, a, move |v| {
|
||||
on_change([r, g, b, v])
|
||||
}).width(Length::Fill);
|
||||
|
||||
// Hex input
|
||||
let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b);
|
||||
let alpha = a;
|
||||
let hex_input = text_input("#", &hex_str)
|
||||
.on_input(move |input| {
|
||||
let clean = input.trim_start_matches('#').to_uppercase();
|
||||
if clean.len() == 6 {
|
||||
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||
u8::from_str_radix(&clean[0..2], 16),
|
||||
u8::from_str_radix(&clean[2..4], 16),
|
||||
u8::from_str_radix(&clean[4..6], 16),
|
||||
) {
|
||||
return on_change([r, g, b, alpha]);
|
||||
}
|
||||
}
|
||||
Message::NoOp
|
||||
})
|
||||
.width(70)
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(10);
|
||||
|
||||
column![
|
||||
row![text(label).size(10), swatch].spacing(4).align_y(iced::Alignment::Center),
|
||||
row![text("H").size(9), h_slider, text(format!("{:.0}°", hsv.h)).size(9)].spacing(2),
|
||||
row![text("S").size(9), s_slider, text(format!("{:.0}%", hsv.s * 100.0)).size(9)].spacing(2),
|
||||
row![text("V").size(9), v_slider, text(format!("{:.0}%", hsv.v * 100.0)).size(9)].spacing(2),
|
||||
row![text("A").size(9), a_slider, text(format!("{:.0}%", a as f32 / 255.0 * 100.0)).size(9)].spacing(2),
|
||||
row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A predefined color palette (10x10 grid) — generated algorithmically to match egui.
|
||||
fn palette_color(row: usize, col: usize) -> [u8; 3] {
|
||||
let lightness = (95.0 - row as f32 * 80.0 / 9.0) / 100.0;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! for the active vector layer's shapes.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::color_picker;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape};
|
||||
@@ -294,57 +295,13 @@ pub fn view(
|
||||
);
|
||||
|
||||
if cur_fill {
|
||||
// Fill color: inline RGB sliders
|
||||
let r = fc[0];
|
||||
let g = fc[1];
|
||||
let b_val = fc[2];
|
||||
let a = fc[3];
|
||||
|
||||
// Fill color: HSV color picker
|
||||
content = content.push(Space::with_height(2));
|
||||
content = content.push(
|
||||
row![
|
||||
text("R").size(9).width(14),
|
||||
slider(0.0..=255.0, r as f32, move |v| Message::GeometryFillColorChanged([v as u8, g, b_val, a])).step(1.0).width(Length::Fill),
|
||||
text(format!("{}", r)).size(9).width(24),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
content = content.push(
|
||||
row![
|
||||
text("G").size(9).width(14),
|
||||
slider(0.0..=255.0, g as f32, move |v| Message::GeometryFillColorChanged([r, v as u8, b_val, a])).step(1.0).width(Length::Fill),
|
||||
text(format!("{}", g)).size(9).width(24),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
content = content.push(
|
||||
row![
|
||||
text("B").size(9).width(14),
|
||||
slider(0.0..=255.0, b_val as f32, move |v| Message::GeometryFillColorChanged([r, g, v as u8, a])).step(1.0).width(Length::Fill),
|
||||
text(format!("{}", b_val)).size(9).width(24),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
|
||||
// Color preview swatch
|
||||
content = content.push(
|
||||
container(Space::with_width(Length::Fill))
|
||||
.width(Length::Fill)
|
||||
.height(14)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
r as f32 / 255.0,
|
||||
g as f32 / 255.0,
|
||||
b_val as f32 / 255.0,
|
||||
a as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(2).color(colors.border_high).width(1),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
content = content.push(color_picker::color_popup(
|
||||
fc,
|
||||
"Fill",
|
||||
Message::GeometryFillColorChanged,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,59 +309,11 @@ pub fn view(
|
||||
// Stroke color
|
||||
let stroke_color = shapes[idx].color();
|
||||
content = content.push(Space::with_height(4));
|
||||
content = content.push(
|
||||
row![text("Stroke").size(10)].spacing(4),
|
||||
);
|
||||
|
||||
let sr = stroke_color[0];
|
||||
let sg = stroke_color[1];
|
||||
let sb = stroke_color[2];
|
||||
let sa = stroke_color[3];
|
||||
|
||||
content = content.push(
|
||||
row![
|
||||
text("R").size(9).width(14),
|
||||
slider(0.0..=255.0, sr as f32, move |v| Message::GeometryStrokeColorChanged([v as u8, sg, sb, sa])).step(1.0).width(Length::Fill),
|
||||
text(format!("{}", sr)).size(9).width(24),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
content = content.push(
|
||||
row![
|
||||
text("G").size(9).width(14),
|
||||
slider(0.0..=255.0, sg as f32, move |v| Message::GeometryStrokeColorChanged([sr, v as u8, sb, sa])).step(1.0).width(Length::Fill),
|
||||
text(format!("{}", sg)).size(9).width(24),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
content = content.push(
|
||||
row![
|
||||
text("B").size(9).width(14),
|
||||
slider(0.0..=255.0, sb as f32, move |v| Message::GeometryStrokeColorChanged([sr, sg, v as u8, sa])).step(1.0).width(Length::Fill),
|
||||
text(format!("{}", sb)).size(9).width(24),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
|
||||
// Stroke color preview swatch
|
||||
content = content.push(
|
||||
container(Space::with_width(Length::Fill))
|
||||
.width(Length::Fill)
|
||||
.height(14)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
sr as f32 / 255.0,
|
||||
sg as f32 / 255.0,
|
||||
sb as f32 / 255.0,
|
||||
sa as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(2).color(colors.border_high).width(1),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
content = content.push(color_picker::color_popup(
|
||||
stroke_color,
|
||||
"Stroke",
|
||||
Message::GeometryStrokeColorChanged,
|
||||
));
|
||||
|
||||
// Opacity slider
|
||||
let cur_opacity = shapes[idx].shape_opacity();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! and a shape is selected, shows that shape's editable properties.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::color_picker;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape};
|
||||
@@ -250,6 +251,8 @@ fn vector_select_properties<'a>(
|
||||
let opacity = shape.shape_opacity();
|
||||
let hardness = shape.hardness();
|
||||
let filled = shape.is_filled();
|
||||
let stroke_color = shape.color();
|
||||
let fill_color = shape.fill_color().unwrap_or([0, 0, 0, 255]);
|
||||
|
||||
let shape_type = match shape {
|
||||
VectorShape::Line { .. } => "Line",
|
||||
@@ -294,6 +297,22 @@ fn vector_select_properties<'a>(
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
// Fill color picker (only if fill is enabled)
|
||||
if filled {
|
||||
color_picker::color_popup(
|
||||
fill_color,
|
||||
"Fill Color",
|
||||
Message::GeometryFillColorChanged,
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
},
|
||||
// Stroke color picker
|
||||
color_picker::color_popup(
|
||||
stroke_color,
|
||||
"Stroke Color",
|
||||
Message::GeometryStrokeColorChanged,
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
@@ -319,6 +338,11 @@ fn vector_tool_properties<'a>(
|
||||
vector_draw: Option<((f32, f32), (f32, f32))>,
|
||||
_colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
// Get current fg/bg colors from app state (we need to pass them through)
|
||||
// For now, use default colors - the actual colors will come from the app state
|
||||
let default_color = [0, 0, 0, 255];
|
||||
let default_fill_color = [255, 255, 255, 255];
|
||||
|
||||
let mut col = column![
|
||||
text("Vector Shape").size(11),
|
||||
row![text("Stroke").size(10), text(format!("{:.1}", stroke)).size(10)].spacing(4),
|
||||
@@ -337,6 +361,22 @@ fn vector_tool_properties<'a>(
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
// Fill color picker (only if fill is enabled)
|
||||
if fill {
|
||||
color_picker::color_popup(
|
||||
default_fill_color,
|
||||
"Fill Color",
|
||||
Message::GeometryFillColorChanged,
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
},
|
||||
// Stroke color picker
|
||||
color_picker::color_popup(
|
||||
default_color,
|
||||
"Stroke Color",
|
||||
Message::GeometryStrokeColorChanged,
|
||||
),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user