From e53d144626d05bd53df8943eb5708fea25072031 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Thu, 16 Jul 2026 05:26:54 +0300 Subject: [PATCH] feat(iced): implement full geometry panel with all vector shape controls - Shape list with selection and delete button (B1) - Boolean operations: Union/Intersect/Subtract/Xor (B7) - Bounds editing with X1/Y1/X2/Y2 sliders and size display (B2) - Fill toggle + fill color with RGB sliders (B3) - Stroke color with RGB sliders (B4) - Opacity and Hardness sliders (B5) - Line Cap controls for Line shapes (B6) --- hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 156 ++++++ .../crates/hcie-iced-gui/src/dock/view.rs | 8 +- .../hcie-iced-gui/src/panels/geometry.rs | 527 ++++++++++++++++-- 3 files changed, 632 insertions(+), 59 deletions(-) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index b55cb2c..7193832 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -260,6 +260,10 @@ pub struct HcieIcedApp { pub vector_shapes_snapshot: Option>, /// Last drag position during vector shape editing (canvas coordinates). pub vector_drag_last: Option<(f32, f32)>, + /// Boolean operation shape A selector index (geometry panel). + pub bool_shape_a: Option, + /// Boolean operation shape B selector index (geometry panel). + pub bool_shape_b: Option, } /// A recently opened file entry. @@ -633,6 +637,22 @@ pub enum Message { VectorShapeFlipH, VectorShapeFlipV, + // ── Geometry panel controls ────────────────────────── + GeometryBoundsX1(f32), + GeometryBoundsY1(f32), + GeometryBoundsX2(f32), + GeometryBoundsY2(f32), + GeometryFillToggled(bool), + GeometryFillColorChanged([u8; 4]), + GeometryStrokeColorChanged([u8; 4]), + GeometryOpacityChanged(f32), + GeometryHardnessChanged(f32), + GeometryBoolShapeA(Option), + GeometryBoolShapeB(Option), + GeometryBooleanOp(hcie_engine_api::VectorBooleanOp), + GeometryLineCapStart(hcie_engine_api::LineCap), + GeometryLineCapEnd(hcie_engine_api::LineCap), + // ── Lasso / polygon selection ─────────────────────── LassoDragMove(f32, f32), LassoDragEnd, @@ -1086,6 +1106,8 @@ impl HcieIcedApp { colors_dirty: false, vector_shapes_snapshot: None, vector_drag_last: None, + bool_shape_a: None, + bool_shape_b: None, }; // Apply saved settings to tool state @@ -3616,6 +3638,140 @@ impl HcieIcedApp { } } + // ── Geometry panel controls ─────────────────── + Message::GeometryBoundsX1(v) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + if let Some(s) = shapes.get(idx) { + let (_, y1, x2, y2) = s.bounds(); + self.documents[self.active_doc].engine.set_vector_shape_bounds(layer_id, idx, v, y1, x2, y2); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + } + Message::GeometryBoundsY1(v) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + if let Some(s) = shapes.get(idx) { + let (x1, _, x2, y2) = s.bounds(); + self.documents[self.active_doc].engine.set_vector_shape_bounds(layer_id, idx, x1, v, x2, y2); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + } + Message::GeometryBoundsX2(v) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + if let Some(s) = shapes.get(idx) { + let (x1, y1, _, y2) = s.bounds(); + self.documents[self.active_doc].engine.set_vector_shape_bounds(layer_id, idx, x1, y1, v, y2); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + } + Message::GeometryBoundsY2(v) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + if let Some(s) = shapes.get(idx) { + let (x1, y1, x2, _) = s.bounds(); + self.documents[self.active_doc].engine.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, v); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + } + Message::GeometryFillToggled(v) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + self.documents[self.active_doc].engine.set_vector_shape_fill(layer_id, idx, v); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + Message::GeometryFillColorChanged(c) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + self.documents[self.active_doc].engine.set_vector_shape_fill_color(layer_id, idx, c); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + Message::GeometryStrokeColorChanged(c) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + self.documents[self.active_doc].engine.set_vector_shape_color(layer_id, idx, c); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + Message::GeometryOpacityChanged(v) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + self.documents[self.active_doc].engine.set_vector_shape_opacity(layer_id, idx, v); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + Message::GeometryHardnessChanged(v) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + self.documents[self.active_doc].engine.set_vector_shape_hardness(layer_id, idx, v); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + Message::GeometryBoolShapeA(idx) => { + self.bool_shape_a = idx; + } + Message::GeometryBoolShapeB(idx) => { + self.bool_shape_b = idx; + } + Message::GeometryBooleanOp(op) => { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + if let (Some(a), Some(b)) = (self.bool_shape_a, self.bool_shape_b) { + self.documents[self.active_doc].engine.boolean_vector_shapes(layer_id, op, a, b); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + Message::GeometryLineCapStart(cap) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + if let Some(s) = shapes.get(idx) { + let ce = match s { + hcie_engine_api::VectorShape::Line { cap_end, .. } => *cap_end, + _ => hcie_engine_api::LineCap::Round, + }; + self.documents[self.active_doc].engine.set_vector_shape_line_caps(layer_id, idx, cap, ce); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + } + Message::GeometryLineCapEnd(cap) => { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + if let Some(s) = shapes.get(idx) { + let cs = match s { + hcie_engine_api::VectorShape::Line { cap_start, .. } => *cap_start, + _ => hcie_engine_api::LineCap::Round, + }; + self.documents[self.active_doc].engine.set_vector_shape_line_caps(layer_id, idx, cs, cap); + self.documents[self.active_doc].engine.mark_composite_dirty(); + self.refresh_composite_if_needed(); + } + } + } + // ── Lasso / polygon selection ───────────────── Message::LassoDragMove(x, y) => { let cx = x.round().max(0.0) as u32; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index 3c085d5..b7e2a4e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -104,7 +104,13 @@ pub fn dock_view<'a>( } PaneType::Geometry => { let shapes = app.documents[app.active_doc].engine.active_vector_shapes().unwrap_or_default(); - crate::panels::geometry::view(shapes, colors, app.documents[app.active_doc].selected_vector_shape) + crate::panels::geometry::view( + shapes, + colors, + app.documents[app.active_doc].selected_vector_shape, + app.bool_shape_a, + app.bool_shape_b, + ) } PaneType::ToolSettings => { let fg = app.fg_color; 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 1de8320..2db24f6 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 @@ -1,85 +1,496 @@ -//! Vector geometry panel — shape properties for vector layers. -//! Uses ThemeColors for consistent styling. +//! Vector geometry panel — full shape properties editor. +//! +//! Renders the shape list with selection, boolean operations, bounds editing, +//! fill/stroke controls, opacity/hardness sliders, and line cap controls +//! for the active vector layer's shapes. use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; -use hcie_engine_api::VectorShape; -use iced::widget::{column, horizontal_rule, scrollable, text}; +use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape}; +use iced::widget::{ + button, checkbox, column, container, row, scrollable, slider, text, Space, +}; use iced::{Element, Length}; -/// Build the geometry panel for vector shapes. +/// Human-readable label for a shape at a given index. +fn shape_label(shapes: &[VectorShape], i: usize) -> String { + match &shapes[i] { + VectorShape::Line { .. } => format!("Line #{}", i + 1), + VectorShape::Rect { .. } => format!("Rect #{}", i + 1), + VectorShape::Circle { .. } => format!("Circle #{}", i + 1), + VectorShape::Arrow { .. } => format!("Arrow #{}", i + 1), + VectorShape::Star { .. } => format!("Star #{}", i + 1), + VectorShape::Polygon { .. } => format!("Polygon #{}", i + 1), + VectorShape::Rhombus { .. } => format!("Rhombus #{}", i + 1), + VectorShape::Cylinder { .. } => format!("Cylinder #{}", i + 1), + VectorShape::Heart { .. } => format!("Heart #{}", i + 1), + VectorShape::Bubble { .. } => format!("Bubble #{}", i + 1), + VectorShape::Gear { .. } => format!("Gear #{}", i + 1), + VectorShape::Cross { .. } => format!("Cross #{}", i + 1), + VectorShape::Crescent { .. } => format!("Crescent #{}", i + 1), + VectorShape::Bolt { .. } => format!("Bolt #{}", i + 1), + VectorShape::Arrow4 { .. } => format!("4-way Arrow #{}", i + 1), + VectorShape::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1), + } +} + +/// Build a section header bar with the given label. +fn section_header(label: String, colors: ThemeColors) -> Element<'static, Message> { + container( + row![text(label).size(11)] + .spacing(4), + ) + .width(Length::Fill) + .padding([3, 6]) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_hover)), + border: iced::Border::default().color(colors.border_high).width(1), + ..Default::default() + }) + .into() +} + +/// Build a labeled row with a slider. +fn labeled_slider<'a>( + label: &'a str, + value: f32, + range: std::ops::RangeInclusive, + on_change: fn(f32) -> Message, +) -> Element<'a, Message> { + row![ + text(label).size(10).width(30), + slider(range, value, on_change).step(0.5).width(Length::Fill), + text(format!("{:.0}", value)).size(9).width(32), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .into() +} + +/// Build the geometry panel. +/// /// Panel title is in the dock tab — no duplicate inside the panel. -pub fn view(shapes: Vec, colors: ThemeColors, selected_shape: Option) -> Element<'static, Message> { - let panel = if shapes.is_empty() { - column![ - text("No vector shapes").size(11), - text("Use vector tools to create shapes").size(10), - ] - } else { - let mut shape_list = column![].spacing(2); - let shape_count = shapes.len(); +pub fn view( + shapes: Vec, + colors: ThemeColors, + selected_shape: Option, + bool_shape_a: Option, + bool_shape_b: Option, +) -> Element<'static, Message> { + let has_shapes = !shapes.is_empty(); + let shape_count = shapes.len(); - for (i, shape) in shapes.into_iter().enumerate() { - let (shape_name, shape_info) = match &shape { - VectorShape::Line { x1, y1, x2, y2, stroke, .. } => ("Line", format!("({:.0},{:.0})→({:.0},{:.0}) w:{:.1}", x1, y1, x2, y2, stroke)), - VectorShape::Rect { x1, y1, x2, y2, fill, stroke, .. } => ("Rect", format!("{}×{} fill:{} stroke:{:.1}", (x2-x1).abs() as u32, (y2-y1).abs() as u32, fill, stroke)), - VectorShape::Circle { x1, y1, x2, y2, fill, stroke, .. } => ("Circle", format!("r:{:.0} fill:{} stroke:{:.1}", ((x2-x1).abs() + (y2-y1).abs()) / 4.0, fill, stroke)), - _ => ("Shape", "Custom".to_string()), - }; + let mut content = column![].spacing(0).padding(4); + // ── SHAPES section ────────────────────────────────────── + content = content.push(section_header("SHAPES".to_string(), colors)); + + if has_shapes { + // Delete button in header row (right-aligned) + if selected_shape.is_some() { + content = content.push( + container( + row![ + Space::with_width(Length::Fill), + button(text("X").size(10)) + .on_press(Message::VectorShapeDelete) + .padding([2, 6]), + ] + .width(Length::Fill), + ) + .padding([2, 0]), + ); + } + + // Shape list + let mut shape_list = column![].spacing(1); + for i in 0..shape_count { + let label = shape_label(&shapes, i); let is_selected = selected_shape == Some(i); let c = colors; - let item = iced::widget::button( - column![text(format!("{}. {}", i + 1, shape_name)).size(11), text(shape_info).size(9)].spacing(2) - ) - .on_press(Message::VectorShapeSelect(Some(i))) - .width(Length::Fill) - .padding([4, 6]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { - iced::widget::button::Status::Active => { - if is_selected { + let item = button(text(label).size(11)) + .on_press(Message::VectorShapeSelect(if is_selected { None } else { Some(i) })) + .width(Length::Fill) + .padding([4, 6]) + .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { + match status { + iced::widget::button::Status::Active => { + if is_selected { + iced::widget::button::Style { + background: Some(iced::Background::Color(c.accent)), + text_color: iced::Color::WHITE, + ..Default::default() + } + } else { + iced::widget::button::Style { + background: Some(iced::Background::Color(c.bg_hover)), + text_color: c.text_primary, + ..Default::default() + } + } + } + iced::widget::button::Status::Hovered => { iced::widget::button::Style { - background: Some(iced::Background::Color(c.accent)), + background: Some(iced::Background::Color(if is_selected { c.accent } else { c.bg_active })), text_color: iced::Color::WHITE, ..Default::default() } - } else { - iced::widget::button::Style { - background: Some(iced::Background::Color(c.bg_hover)), - text_color: c.text_primary, - ..Default::default() - } } - } - iced::widget::button::Status::Hovered => { - iced::widget::button::Style { - background: Some(iced::Background::Color(if is_selected { c.accent } else { c.bg_active })), - text_color: iced::Color::WHITE, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(c.bg_hover)), + text_color: c.text_primary, ..Default::default() - } + }, } - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(c.bg_hover)), - text_color: c.text_primary, - ..Default::default() - }, - } - }); + }); shape_list = shape_list.push(item); } - column![ - text(format!("{} shapes", shape_count)).size(10), - horizontal_rule(1), - scrollable(shape_list).height(Length::Fill), - ] - }; + content = content.push( + scrollable(shape_list).height(Length::Fill).width(Length::Fill), + ); + } else { + content = content.push( + column![ + text("No vector shapes").size(11), + text("Use vector tools to create shapes").size(10), + ] + .spacing(2), + ); + } - iced::widget::container(panel.spacing(0).padding(4)) + // ── PATH BOOLEAN section (when 2+ shapes) ─────────────── + if shape_count >= 2 { + content = content.push(Space::with_height(8)); + content = content.push(section_header("PATH BOOLEAN".to_string(), colors)); + content = content.push(Space::with_height(4)); + + // Shape A selector + let a_label = match bool_shape_a { + Some(i) if i < shape_count => shape_label(&shapes, i), + _ => "Shape A".to_string(), + }; + let mut a_menu = column![].spacing(1); + for i in 0..shape_count { + let lbl = shape_label(&shapes, i); + let idx = i; + a_menu = a_menu.push( + button(text(lbl).size(10)) + .on_press(Message::GeometryBoolShapeA(Some(idx))) + .width(Length::Fill) + .padding([3, 6]), + ); + } + + // Shape B selector + let b_label = match bool_shape_b { + Some(i) if i < shape_count => shape_label(&shapes, i), + _ => "Shape B".to_string(), + }; + let mut b_menu = column![].spacing(1); + for i in 0..shape_count { + let lbl = shape_label(&shapes, i); + let idx = i; + b_menu = b_menu.push( + button(text(lbl).size(10)) + .on_press(Message::GeometryBoolShapeB(Some(idx))) + .width(Length::Fill) + .padding([3, 6]), + ); + } + + // Show selectors inline + content = content.push( + row![ + container( + column![ + button(text(a_label).size(10)) + .width(Length::Fill) + .padding([4, 6]), + a_menu, + ] + .spacing(2) + ).width(Length::Fill).style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_active)), + border: iced::Border::default().color(colors.border_high).width(1).rounded(2), + ..Default::default() + }), + text(" + ").size(10), + container( + column![ + button(text(b_label).size(10)) + .width(Length::Fill) + .padding([4, 6]), + b_menu, + ] + .spacing(2) + ).width(Length::Fill).style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_active)), + border: iced::Border::default().color(colors.border_high).width(1).rounded(2), + ..Default::default() + }), + ] + .spacing(4) + .width(Length::Fill), + ); + + // Boolean operation buttons + content = content.push(Space::with_height(4)); + content = content.push( + row![ + button(text("Union").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union)).padding([3, 8]), + button(text("Intersect").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Intersect)).padding([3, 8]), + button(text("Subtract").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Subtract)).padding([3, 8]), + button(text("Xor").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Xor)).padding([3, 8]), + ] + .spacing(4) + .width(Length::Fill), + ); + } + + // ── GEOMETRY section (selected shape only) ────────────── + if let Some(idx) = selected_shape { + if idx < shape_count { + content = content.push(Space::with_height(8)); + content = content.push(section_header(format!("GEOMETRY [#{}]", idx + 1), colors)); + content = content.push(Space::with_height(4)); + + let (x1, y1, x2, y2) = shapes[idx].bounds(); + let w = (x2 - x1).abs(); + let h = (y2 - y1).abs(); + + // Bounds sliders + content = content.push(labeled_slider("X1", x1, -2000.0..=5000.0, Message::GeometryBoundsX1)); + content = content.push(labeled_slider("Y1", y1, -2000.0..=5000.0, Message::GeometryBoundsY1)); + content = content.push(labeled_slider("X2", x2, -2000.0..=5000.0, Message::GeometryBoundsX2)); + content = content.push(labeled_slider("Y2", y2, -2000.0..=5000.0, Message::GeometryBoundsY2)); + + // Size display + content = content.push( + text(format!("Size: {:.0} x {:.0}", w, h)) + .size(10) + .style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + ..Default::default() + }), + ); + + // Fill controls (not for Line shapes) + let is_line = matches!(&shapes[idx], VectorShape::Line { .. }); + if !is_line { + if let Some(fc) = shapes[idx].fill_color() { + let cur_fill = shapes[idx].fill().unwrap_or(false); + content = content.push(Space::with_height(4)); + content = content.push( + row![ + checkbox("", cur_fill).on_toggle(Message::GeometryFillToggled), + text("Fill").size(10), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + ); + + 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]; + + 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() + }), + ); + } + } + } + + // 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() + }), + ); + + // Opacity slider + let cur_opacity = shapes[idx].shape_opacity(); + content = content.push(Space::with_height(4)); + content = content.push(labeled_slider("Opacity", cur_opacity, 0.0..=1.0, Message::GeometryOpacityChanged)); + + // Hardness slider + let cur_hardness = shapes[idx].hardness(); + content = content.push(labeled_slider("Hardness", cur_hardness, 0.0..=1.0, Message::GeometryHardnessChanged)); + + // Line caps (for Line shapes only) + if is_line { + let (cs, ce) = match &shapes[idx] { + VectorShape::Line { cap_start, cap_end, .. } => (*cap_start, *cap_end), + _ => (LineCap::Round, LineCap::Round), + }; + + content = content.push(Space::with_height(4)); + content = content.push(section_header("LINE CAPS".to_string(), colors)); + content = content.push(Space::with_height(2)); + + // Start cap combo + content = content.push( + row![ + text("Start").size(10).width(40), + button(text(cs.label()).size(10)).padding([3, 6]).width(Length::Fill), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + ); + + // Show cap options when clicked (inline list) + let mut cap_start_list = column![].spacing(1); + for &cap in LineCap::ALL { + let cap_val = cap; + let is_active = cs == cap; + cap_start_list = cap_start_list.push( + button( + row![ + if is_active { text(">").size(10) } else { text(" ").size(10) }, + text(cap.label()).size(10), + ] + .spacing(4) + ) + .on_press(Message::GeometryLineCapStart(cap_val)) + .width(Length::Fill) + .padding([2, 6]), + ); + } + content = content.push(cap_start_list); + + content = content.push(Space::with_height(2)); + + // End cap combo + content = content.push( + row![ + text("End").size(10).width(40), + button(text(ce.label()).size(10)).padding([3, 6]).width(Length::Fill), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + ); + + let mut cap_end_list = column![].spacing(1); + for &cap in LineCap::ALL { + let cap_val = cap; + let is_active = ce == cap; + cap_end_list = cap_end_list.push( + button( + row![ + if is_active { text(">").size(10) } else { text(" ").size(10) }, + text(cap.label()).size(10), + ] + .spacing(4) + ) + .on_press(Message::GeometryLineCapEnd(cap_val)) + .width(Length::Fill) + .padding([2, 6]), + ); + } + content = content.push(cap_end_list); + } + } + } + + container(content.spacing(0)) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| styles::panel_background(colors))