From 2dc376a54b96f460b0ca9c51e2320bcf59693288 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Thu, 16 Jul 2026 12:10:12 +0300 Subject: [PATCH] feat(iced): implement full HSV color picker for geometry and properties panels --- .../crates/hcie-iced-gui/src/color_picker.rs | 146 ++++++++++++++++++ .../hcie-iced-gui/src/panels/geometry.rs | 115 ++------------ .../hcie-iced-gui/src/panels/properties.rs | 40 +++++ 3 files changed, 198 insertions(+), 103 deletions(-) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs index 3eb5aa1..c55d9cc 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs @@ -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; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs index 2db24f6..c41ddcb 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs @@ -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(); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs index 37cd7e8..8c8b678 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs @@ -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);