From bbabd2acdb02f9c1807348b064d07c0011359822 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Sat, 18 Jul 2026 23:02:28 +0300 Subject: [PATCH] Refactor and enhance GUI components - Improved code readability by formatting function parameters and conditionals. - Updated tooltip implementations to use a consistent style across various components. - Added vector angle property to tool settings and integrated it into the vector shape rendering logic. - Enhanced the rendering of shapes such as Cross, Bolt, and Arrow4 to improve visual fidelity. - Refactored dock and sidebar components for better organization and clarity. - Fixed various minor issues and improved logging for better debugging. --- hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 576 ++++++++------- .../crates/hcie-iced-gui/src/canvas/mod.rs | 685 ++++++++++++------ .../hcie-iced-gui/src/canvas/shader_canvas.rs | 14 +- .../crates/hcie-iced-gui/src/color_picker.rs | 20 +- .../hcie-iced-gui/src/dock/auto_hide.rs | 27 +- .../crates/hcie-iced-gui/src/dock/floating.rs | 24 +- .../crates/hcie-iced-gui/src/dock/preview.rs | 18 +- .../crates/hcie-iced-gui/src/dock/sizing.rs | 57 +- .../crates/hcie-iced-gui/src/dock/state.rs | 9 +- .../crates/hcie-iced-gui/src/dock/view.rs | 87 ++- .../crates/hcie-iced-gui/src/dock/welcome.rs | 5 +- .../hcie-iced-gui/src/panels/brushes.rs | 5 +- .../hcie-iced-gui/src/panels/properties.rs | 14 +- .../crates/hcie-iced-gui/src/settings.rs | 4 + .../crates/hcie-iced-gui/src/shape_sync.rs | 8 + .../crates/hcie-iced-gui/src/sidebar/mod.rs | 86 ++- hcie-vector/src/lib.rs | 110 ++- 17 files changed, 1076 insertions(+), 673 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 a123c44..0962ae9 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -14,6 +14,7 @@ mod transform_dirty; +use self::transform_dirty::preview_bounds; use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT}; use crate::dialogs; use crate::dock::manager::DockDragState; @@ -24,7 +25,6 @@ use crate::panels; use crate::selection; use crate::selection::{CropState, SelectionTransform, TransformHandle}; use crate::theme::ThemeState; -use self::transform_dirty::preview_bounds; use hcie_engine_api::{ BlendMode, BrushStyle, BrushTip, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN, }; @@ -292,6 +292,10 @@ pub struct HcieIcedApp { pub vector_drag_last: Option<(f32, f32)>, /// Timestamp of the last expensive vector rerasterization during a drag. pub vector_drag_last_render: Option, + /// Last composite bounds during drag — used to skip redundant re-renders. + pub vector_drag_last_bounds: Option<(f32, f32, f32, f32)>, + /// Last composite angle during drag — used to skip redundant re-renders. + pub vector_drag_last_angle: Option, /// Immutable basis for the active vector drag. pub vector_drag_session: Option, /// Boolean operation shape A selector index (geometry panel). @@ -434,6 +438,9 @@ pub struct IcedDocument { pub vector_cycle_index: usize, /// Which vector edit handle is active (hovered or being dragged). pub vector_edit_handle: hcie_engine_api::VectorEditHandle, + /// Transformed vector shape being previewed during a drag (not yet committed + /// to the engine). Kept in the document so the overlay canvas can draw it. + pub vector_drag_preview: Option, } /// In-progress text layer being authored with the Text tool. @@ -672,6 +679,7 @@ pub enum Message { VectorRadiusChanged(f32), VectorPointsChanged(u32), VectorSidesChanged(u32), + VectorAngleChanged(f32), TextFontChanged(String), TextAngleChanged(f32), TextOrientationChanged(String), @@ -778,6 +786,7 @@ pub enum Message { GeometryBoundsY1(f32), GeometryBoundsX2(f32), GeometryBoundsY2(f32), + GeometryAngleChanged(f32), GeometryFillToggled(bool), GeometryFillColorChanged([u8; 4]), GeometryStrokeColorChanged([u8; 4]), @@ -1219,7 +1228,12 @@ fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32 let upload_bounds = union_regions(pending, bounds); doc.dirty_region.replace(Some(upload_bounds)); if let Some(shader_pixels) = Arc::get_mut(&mut doc.composite_pixels) { - restore_preview_region(shader_pixels, &doc.composite_raw, canvas_width, upload_bounds); + restore_preview_region( + shader_pixels, + &doc.composite_raw, + canvas_width, + upload_bounds, + ); } else { doc.composite_pixels = Arc::new(doc.composite_raw.clone()); } @@ -1239,7 +1253,7 @@ impl HcieIcedApp { /// through `hcie-engine-api` when add/subtract/intersect mode is active. fn combine_current_selection(&mut self, previous: Option>) { let doc = &mut self.documents[self.active_doc]; - + let current_marker = doc.engine.history_current(); if current_marker != doc.selection_history_marker { doc.selection_history.clear(); @@ -1321,6 +1335,7 @@ impl HcieIcedApp { selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, + vector_drag_preview: None, }; // Load saved settings @@ -1336,7 +1351,8 @@ impl HcieIcedApp { last_cursor_pos: None, canvas_cursor_pos: None, active_dialog: ActiveDialog::None, - active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush).unwrap_or(0), + active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush) + .unwrap_or(0), sub_tools_open: false, sidebar_expanded: settings.panel_layout.sidebar_expanded, slot_last_used: vec![0; 20], @@ -1423,6 +1439,8 @@ impl HcieIcedApp { vector_shapes_snapshot: None, vector_drag_last: None, vector_drag_last_render: None, + vector_drag_last_bounds: None, + vector_drag_last_angle: None, vector_drag_session: None, bool_shape_a: None, bool_shape_b: None, @@ -1444,7 +1462,8 @@ impl HcieIcedApp { { app.dock.reopen_pane(panel); if let Some(pane) = app.dock.pane_for_type(panel) { - app.dock.float_pane(pane, (settings.window.width, settings.window.height)); + app.dock + .float_pane(pane, (settings.window.width, settings.window.height)); } } if let Some(panel) = app @@ -1455,10 +1474,8 @@ impl HcieIcedApp { { app.dock.reopen_pane(panel); if let Some(pane) = app.dock.pane_for_type(panel) { - app.dock.auto_hide_pane( - pane, - Some(crate::dock::manager::DockEdge::Right), - ); + app.dock + .auto_hide_pane(pane, Some(crate::dock::manager::DockEdge::Right)); } } if app @@ -1755,6 +1772,11 @@ impl HcieIcedApp { self.vector_drag_session = None; self.vector_drag_last = None; self.vector_drag_last_render = None; + self.vector_drag_last_bounds = None; + self.vector_drag_last_angle = None; + if let Some(doc) = self.documents.get_mut(self.active_doc) { + doc.vector_drag_preview = None; + } return; } self.documents.remove(document_index); @@ -1766,6 +1788,9 @@ impl HcieIcedApp { self.vector_shapes_snapshot = None; self.vector_drag_session = None; self.vector_drag_last = None; + if let Some(doc) = self.documents.get_mut(self.active_doc) { + doc.vector_drag_preview = None; + } } /// Add a color to the recent colors list (most recent first, max 40). @@ -1804,16 +1829,16 @@ impl HcieIcedApp { let y0 = y0 as usize; let x1 = x1 as usize; let y1 = y1 as usize; - + let row_bytes = (x1 - x0) * 4; if row_bytes > 0 { unsafe { let src_ptr = ptr; let dst_raw_ptr = doc.composite_raw.as_mut_ptr(); - + let mut pixels_ptr = std::ptr::null_mut(); let mut need_new_arc = false; - + if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) { // Important: resize arc if needed before taking mutable pointer if pixels.len() != len { @@ -1829,17 +1854,17 @@ impl HcieIcedApp { std::ptr::copy_nonoverlapping( src_ptr.add(offset), dst_raw_ptr.add(offset), - row_bytes + row_bytes, ); if !pixels_ptr.is_null() { std::ptr::copy_nonoverlapping( src_ptr.add(offset), pixels_ptr.add(offset), - row_bytes + row_bytes, ); } } - + if need_new_arc { doc.composite_pixels = Arc::new(doc.composite_raw.clone()); } @@ -2487,106 +2512,112 @@ impl HcieIcedApp { if is_selection_tool { // Fall through to the dedicated selection drag handlers below. } else { - let is_ctrl = self.modifiers.control() || self.modifiers.logo(); - if is_ctrl { - // Pick color - let cx = x as u32; - let cy = y as u32; - let w = self.documents[self.active_doc].engine.canvas_width(); - let h = self.documents[self.active_doc].engine.canvas_height(); - if cx < w && cy < h { - let pixels = &self.documents[self.active_doc].composite_raw; - let idx = ((cy * w + cx) * 4) as usize; - if idx + 3 < pixels.len() { - let color = [ - pixels[idx], - pixels[idx + 1], - pixels[idx + 2], - pixels[idx + 3], - ]; - self.fg_color = color; - self.documents[self.active_doc].engine.set_color(color); - } - } - } else if self.tool_state.is_resizing_brush { - if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos { - let dx = x - start_x; - let new_size = - (self.tool_state.brush_resize_start_size + dx).clamp(1.0, 500.0); - self.tool_state.brush_size = new_size; - match self.tool_state.active_tool { - Tool::Pen => self.settings.tool_settings.pen_size = new_size, - Tool::Brush => self.settings.tool_settings.brush_size = new_size, - Tool::Eraser => self.settings.tool_settings.eraser_size = new_size, - Tool::Spray => self.settings.tool_settings.spray_radius = new_size, - _ => {} - } - } - } else { - let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - - if self.tool_state.active_tool == Tool::Pen { - if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { - if !self.documents[self.active_doc].engine.is_stroke_active() { - self.documents[self.active_doc] - .engine - .begin_stroke(layer_id, last_x, last_y); + let is_ctrl = self.modifiers.control() || self.modifiers.logo(); + if is_ctrl { + // Pick color + let cx = x as u32; + let cy = y as u32; + let w = self.documents[self.active_doc].engine.canvas_width(); + let h = self.documents[self.active_doc].engine.canvas_height(); + if cx < w && cy < h { + let pixels = &self.documents[self.active_doc].composite_raw; + let idx = ((cy * w + cx) * 4) as usize; + if idx + 3 < pixels.len() { + let color = [ + pixels[idx], + pixels[idx + 1], + pixels[idx + 2], + pixels[idx + 3], + ]; + self.fg_color = color; + self.documents[self.active_doc].engine.set_color(color); } - let pressure = self - .tablet_state - .lock() - .map(|state| state.current_pressure()) - .unwrap_or(1.0); - self.documents[self.active_doc] - .engine - .draw_pen_segment(layer_id, last_x, last_y, x, y, pressure); - self.tool_state.last_stroke_pos = Some((x, y)); - self.refresh_composite_if_needed(); } - return Task::none(); - } - - let spacing: f32 = match self.tool_state.active_tool { - Tool::Spray => 2.0_f32, - _ => 1.0_f32, - } - .max(1.0); - - if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { - let dx = x - last_x; - let dy = y - last_y; - let dist = (dx * dx + dy * dy).sqrt(); - - // Use constant pressure for mouse input - // Real tablet pressure comes from tablet_state - let pressure = self - .tablet_state - .lock() - .map(|s| s.current_pressure()) - .unwrap_or(1.0); - - if dist >= spacing { - self.documents[self.active_doc] - .engine - .stroke_to(layer_id, x, y, pressure); - self.tool_state.last_stroke_pos = Some((x, y)); - - // Throttle composite refresh to ~60 FPS during strokes. - let now = std::time::Instant::now(); - let elapsed = - now.duration_since(self.tool_state.last_composite_refresh); - if elapsed.as_secs_f32() >= 0.016 { - self.tool_state.last_composite_refresh = now; - self.refresh_composite_if_needed(); + } else if self.tool_state.is_resizing_brush { + if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos { + let dx = x - start_x; + let new_size = (self.tool_state.brush_resize_start_size + dx) + .clamp(1.0, 500.0); + self.tool_state.brush_size = new_size; + match self.tool_state.active_tool { + Tool::Pen => self.settings.tool_settings.pen_size = new_size, + Tool::Brush => { + self.settings.tool_settings.brush_size = new_size + } + Tool::Eraser => { + self.settings.tool_settings.eraser_size = new_size + } + Tool::Spray => { + self.settings.tool_settings.spray_radius = new_size + } + _ => {} } } } else { - self.documents[self.active_doc] - .engine - .stroke_to(layer_id, x, y, 1.0); - self.tool_state.last_stroke_pos = Some((x, y)); + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + + if self.tool_state.active_tool == Tool::Pen { + if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { + if !self.documents[self.active_doc].engine.is_stroke_active() { + self.documents[self.active_doc] + .engine + .begin_stroke(layer_id, last_x, last_y); + } + let pressure = self + .tablet_state + .lock() + .map(|state| state.current_pressure()) + .unwrap_or(1.0); + self.documents[self.active_doc] + .engine + .draw_pen_segment(layer_id, last_x, last_y, x, y, pressure); + self.tool_state.last_stroke_pos = Some((x, y)); + self.refresh_composite_if_needed(); + } + return Task::none(); + } + + let spacing: f32 = match self.tool_state.active_tool { + Tool::Spray => 2.0_f32, + _ => 1.0_f32, + } + .max(1.0); + + if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { + let dx = x - last_x; + let dy = y - last_y; + let dist = (dx * dx + dy * dy).sqrt(); + + // Use constant pressure for mouse input + // Real tablet pressure comes from tablet_state + let pressure = self + .tablet_state + .lock() + .map(|s| s.current_pressure()) + .unwrap_or(1.0); + + if dist >= spacing { + self.documents[self.active_doc] + .engine + .stroke_to(layer_id, x, y, pressure); + self.tool_state.last_stroke_pos = Some((x, y)); + + // Throttle composite refresh to ~60 FPS during strokes. + let now = std::time::Instant::now(); + let elapsed = + now.duration_since(self.tool_state.last_composite_refresh); + if elapsed.as_secs_f32() >= 0.016 { + self.tool_state.last_composite_refresh = now; + self.refresh_composite_if_needed(); + } + } + } else { + self.documents[self.active_doc] + .engine + .stroke_to(layer_id, x, y, 1.0); + self.tool_state.last_stroke_pos = Some((x, y)); + } } - } } // end else (non-selection tool) } @@ -2716,17 +2747,17 @@ impl HcieIcedApp { | Tool::SmartSelect ); if !is_selection_tool { - let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc].engine.end_stroke(layer_id); - self.documents[self.active_doc] - .engine - .commit_pending_history(); + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + self.documents[self.active_doc].engine.end_stroke(layer_id); + self.documents[self.active_doc] + .engine + .commit_pending_history(); - self.tool_state.is_drawing = false; - self.tool_state.last_stroke_pos = None; - self.documents[self.active_doc].modified = true; + self.tool_state.is_drawing = false; + self.tool_state.last_stroke_pos = None; + self.documents[self.active_doc].modified = true; - self.refresh_composite_if_needed(); + self.refresh_composite_if_needed(); } // end if !is_selection_tool } @@ -2815,7 +2846,9 @@ impl HcieIcedApp { Message::Undo => { let doc = &mut self.documents[self.active_doc]; let current_marker = doc.engine.history_current(); - if current_marker == doc.selection_history_marker && !doc.selection_history.is_empty() { + if current_marker == doc.selection_history_marker + && !doc.selection_history.is_empty() + { // Undo selection if let Some(prev_mask) = doc.selection_history.pop() { if let Some(mask) = prev_mask { @@ -2856,6 +2889,7 @@ impl HcieIcedApp { self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; + doc.vector_drag_preview = None; self.bool_shape_a = None; self.bool_shape_b = None; doc.engine.set_active_layer(id); @@ -3061,103 +3095,101 @@ impl HcieIcedApp { // - Style doesn't exist → create default style with enabled: true // - Style exists → clone and toggle enabled flag let new_style = match existing { - None => { - match disc { - "DropShadow" => LayerStyle::DropShadow { - enabled: true, - opacity: 0.75, - angle: 120.0, - distance: 5.0, - spread: 0.0, - size: 5.0, - color: [0, 0, 0, 255], - blend_mode: "Multiply".to_string(), - }, - "InnerShadow" => LayerStyle::InnerShadow { - enabled: true, - opacity: 0.75, - angle: 120.0, - distance: 5.0, - spread: 0.0, - size: 5.0, - color: [0, 0, 0, 255], - blend_mode: "Multiply".to_string(), - }, - "OuterGlow" => LayerStyle::OuterGlow { - enabled: true, - opacity: 0.75, - spread: 0.0, - size: 5.0, - color: [255, 255, 190, 255], - blend_mode: "Screen".to_string(), - }, - "InnerGlow" => LayerStyle::InnerGlow { - enabled: true, - opacity: 0.75, - spread: 0.0, - size: 5.0, - color: [255, 255, 190, 255], - blend_mode: "Screen".to_string(), - }, - "BevelEmboss" => LayerStyle::BevelEmboss { - enabled: true, - depth: 1.0, - size: 5.0, - angle: 120.0, - altitude: 30.0, - highlight_opacity: 0.75, - shadow_opacity: 0.75, - direction: "Up".to_string(), - style: "InnerBevel".to_string(), - technique: "Smooth".to_string(), - soften: 0.0, - highlight_blend_mode: "Screen".to_string(), - highlight_color: [255, 255, 255, 255], - shadow_blend_mode: "Multiply".to_string(), - shadow_color: [0, 0, 0, 255], - contour: "Linear".to_string(), - }, - "Satin" => LayerStyle::Satin { - enabled: true, - opacity: 0.5, - angle: 120.0, - distance: 11.0, - size: 14.0, - color: [0, 0, 0, 255], - invert: true, - }, - "ColorOverlay" => LayerStyle::ColorOverlay { - enabled: true, - opacity: 1.0, - color: [255, 0, 0, 255], - blend_mode: "Normal".to_string(), - }, - "GradientOverlay" => LayerStyle::GradientOverlay { - enabled: true, - opacity: 1.0, - blend_mode: "Normal".to_string(), - angle: 90.0, - scale: 1.0, - gradient_type: 0, - }, - "PatternOverlay" => LayerStyle::PatternOverlay { - enabled: true, - opacity: 1.0, - blend_mode: "Normal".to_string(), - scale: 1.0, - pattern_name: "".to_string(), - }, - "Stroke" => LayerStyle::Stroke { - enabled: true, - size: 3.0, - position: "Outside".to_string(), - opacity: 1.0, - color: [255, 0, 0, 255], - blend_mode: "Normal".to_string(), - }, - _ => unreachable!(), - } - } + None => match disc { + "DropShadow" => LayerStyle::DropShadow { + enabled: true, + opacity: 0.75, + angle: 120.0, + distance: 5.0, + spread: 0.0, + size: 5.0, + color: [0, 0, 0, 255], + blend_mode: "Multiply".to_string(), + }, + "InnerShadow" => LayerStyle::InnerShadow { + enabled: true, + opacity: 0.75, + angle: 120.0, + distance: 5.0, + spread: 0.0, + size: 5.0, + color: [0, 0, 0, 255], + blend_mode: "Multiply".to_string(), + }, + "OuterGlow" => LayerStyle::OuterGlow { + enabled: true, + opacity: 0.75, + spread: 0.0, + size: 5.0, + color: [255, 255, 190, 255], + blend_mode: "Screen".to_string(), + }, + "InnerGlow" => LayerStyle::InnerGlow { + enabled: true, + opacity: 0.75, + spread: 0.0, + size: 5.0, + color: [255, 255, 190, 255], + blend_mode: "Screen".to_string(), + }, + "BevelEmboss" => LayerStyle::BevelEmboss { + enabled: true, + depth: 1.0, + size: 5.0, + angle: 120.0, + altitude: 30.0, + highlight_opacity: 0.75, + shadow_opacity: 0.75, + direction: "Up".to_string(), + style: "InnerBevel".to_string(), + technique: "Smooth".to_string(), + soften: 0.0, + highlight_blend_mode: "Screen".to_string(), + highlight_color: [255, 255, 255, 255], + shadow_blend_mode: "Multiply".to_string(), + shadow_color: [0, 0, 0, 255], + contour: "Linear".to_string(), + }, + "Satin" => LayerStyle::Satin { + enabled: true, + opacity: 0.5, + angle: 120.0, + distance: 11.0, + size: 14.0, + color: [0, 0, 0, 255], + invert: true, + }, + "ColorOverlay" => LayerStyle::ColorOverlay { + enabled: true, + opacity: 1.0, + color: [255, 0, 0, 255], + blend_mode: "Normal".to_string(), + }, + "GradientOverlay" => LayerStyle::GradientOverlay { + enabled: true, + opacity: 1.0, + blend_mode: "Normal".to_string(), + angle: 90.0, + scale: 1.0, + gradient_type: 0, + }, + "PatternOverlay" => LayerStyle::PatternOverlay { + enabled: true, + opacity: 1.0, + blend_mode: "Normal".to_string(), + scale: 1.0, + pattern_name: "".to_string(), + }, + "Stroke" => LayerStyle::Stroke { + enabled: true, + size: 3.0, + position: "Outside".to_string(), + opacity: 1.0, + color: [255, 0, 0, 255], + blend_mode: "Normal".to_string(), + }, + _ => unreachable!(), + }, Some(s) => { let mut cloned = s.clone(); match &mut cloned { @@ -4095,32 +4127,51 @@ impl HcieIcedApp { Message::VectorStrokeChanged(s) => { self.settings.tool_settings.vector_stroke_width = s; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); let _ = self.settings.save(); + return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::VectorOpacityChanged(o) => { self.settings.tool_settings.vector_opacity = o; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); let _ = self.settings.save(); + return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::VectorFillToggled(f) => { self.settings.tool_settings.vector_fill = f; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); let _ = self.settings.save(); + return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::VectorRadiusChanged(r) => { self.settings.tool_settings.vector_radius = r; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); let _ = self.settings.save(); + return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::VectorPointsChanged(p) => { self.settings.tool_settings.vector_points = p; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); let _ = self.settings.save(); + return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::VectorSidesChanged(s) => { self.settings.tool_settings.vector_sides = s; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); let _ = self.settings.save(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + Message::VectorAngleChanged(angle_deg) => { + self.settings.tool_settings.vector_angle = angle_deg; + crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); + let _ = self.settings.save(); + return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::TextFontChanged(font) => { self.settings.tool_settings.text_font = font.clone(); @@ -4494,6 +4545,7 @@ impl HcieIcedApp { selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, + vector_drag_preview: None, }; if self.show_welcome && self.documents.len() == 1 { // Replace the placeholder empty document created for the @@ -4709,13 +4761,18 @@ impl HcieIcedApp { self.documents[self.active_doc].selection_rect = None; } Message::SelectAll => { - let previous = self.documents[self.active_doc].engine.get_selection_mask().map(ToOwned::to_owned); + let previous = self.documents[self.active_doc] + .engine + .get_selection_mask() + .map(ToOwned::to_owned); let current_marker = self.documents[self.active_doc].engine.history_current(); if current_marker != self.documents[self.active_doc].selection_history_marker { self.documents[self.active_doc].selection_history.clear(); self.documents[self.active_doc].selection_history_marker = current_marker; } - self.documents[self.active_doc].selection_history.push(previous); + self.documents[self.active_doc] + .selection_history + .push(previous); let w = self.documents[self.active_doc].engine.canvas_width(); let h = self.documents[self.active_doc].engine.canvas_height(); @@ -5270,6 +5327,7 @@ impl HcieIcedApp { doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = VEH::None; + doc.vector_drag_preview = None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; @@ -5280,6 +5338,7 @@ impl HcieIcedApp { doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = VEH::None; + doc.vector_drag_preview = None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; @@ -5321,6 +5380,7 @@ impl HcieIcedApp { { self.vector_drag_last = None; self.vector_drag_session = None; + self.documents[self.active_doc].vector_drag_preview = None; return Task::none(); } let shape = crate::vector_edit::transform_shape( @@ -5331,42 +5391,26 @@ impl HcieIcedApp { ); let (x1, y1, x2, y2) = shape.normalized_bounds(); let angle = shape.angle(); - if let Some(shapes) = self.documents[self.active_doc] - .engine - .get_layer_shapes_direct(session.layer_id) - { - if let Some(current) = shapes.get_mut(session.shape_index) { - *current = shape; - } - } + + // Keep the transformed shape in a lightweight overlay preview + // instead of mutating the engine on every pointer event. The + // expensive vector rasterization and full-layer composite only + // happen once when the drag ends. + self.documents[self.active_doc].vector_drag_preview = Some(shape); self.documents[self.active_doc].modified = true; self.vector_drag_last = Some((x, y)); - let should_render = self - .vector_drag_last_render - .is_none_or(|last| last.elapsed() >= std::time::Duration::from_millis(16)); - if should_render { - let engine = &mut self.documents[self.active_doc].engine; - engine.set_vector_shape_bounds( - session.layer_id, - session.shape_index, - x1, - y1, - x2, - y2, - ); - engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle); - self.vector_drag_last_render = Some(std::time::Instant::now()); - self.refresh_composite_if_needed(); - } + self.vector_drag_last_bounds = Some((x1, y1, x2, y2)); + self.vector_drag_last_angle = Some(angle); + self.settings.tool_settings.vector_angle = angle.to_degrees(); } } Message::VectorSelectDragEnd => { if self.vector_drag_last.is_some() { + // Commit the previewed transform to the engine in one + // transaction, avoiding per-event rasterization. if let Some(session) = self.vector_drag_session.as_ref() { - if let Some(shape) = self.documents[self.active_doc] - .engine - .active_vector_shapes() - .and_then(|shapes| shapes.get(session.shape_index).cloned()) + if let Some(shape) = + self.documents[self.active_doc].vector_drag_preview.take() { let (x1, y1, x2, y2) = shape.normalized_bounds(); let angle = shape.angle(); @@ -5389,7 +5433,10 @@ impl HcieIcedApp { // Commit the move/resize/rotate: if shapes changed, push history snapshot. self.vector_drag_last = None; self.vector_drag_last_render = None; + self.vector_drag_last_bounds = None; + self.vector_drag_last_angle = None; self.vector_drag_session = None; + self.documents[self.active_doc].vector_drag_preview = None; self.documents[self.active_doc].vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.refresh_composite_if_needed(); @@ -5404,6 +5451,7 @@ impl HcieIcedApp { self.documents[self.active_doc].selected_vector_shape = None; self.documents[self.active_doc].modified = true; self.documents[self.active_doc].vector_cycle_index = 0; + self.documents[self.active_doc].vector_drag_preview = None; self.vector_shapes_snapshot = None; self.vector_drag_session = None; self.documents[self.active_doc] @@ -5433,6 +5481,7 @@ impl HcieIcedApp { self.vector_shapes_snapshot = None; } self.vector_drag_session = None; + self.documents[self.active_doc].vector_drag_preview = None; } // ── Vector shape apply / cancel / flip ────── Message::VectorShapeApply => { @@ -5441,6 +5490,7 @@ impl HcieIcedApp { doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; + doc.vector_drag_preview = None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; @@ -5455,6 +5505,7 @@ impl HcieIcedApp { doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; + doc.vector_drag_preview = None; self.vector_drag_last = None; self.vector_drag_session = None; doc.engine.mark_composite_dirty(); @@ -5615,6 +5666,20 @@ impl HcieIcedApp { } } } + Message::GeometryAngleChanged(angle_deg) => { + 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_angle(layer_id, idx, angle_deg.to_radians()); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + 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(); @@ -6524,13 +6589,11 @@ impl HcieIcedApp { .last_cursor_pos .map(|(x, y)| iced::Point::new(x, y)) .unwrap_or_default(); - self.dock.header_drag = Some( - crate::dock::manager::DockHeaderDragState { - source_pane: pane, - source_type, - origin, - }, - ); + self.dock.header_drag = Some(crate::dock::manager::DockHeaderDragState { + source_pane: pane, + source_type, + origin, + }); self.dock.focus(pane); } } @@ -6870,8 +6933,8 @@ impl HcieIcedApp { selection_mask: None, selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, - selection_history: Vec::new(), - selection_history_marker: 0, + selection_history: Vec::new(), + selection_history_marker: 0, crop_state: CropState::default(), lasso_points: Vec::new(), polygon_points: Vec::new(), @@ -6887,6 +6950,7 @@ impl HcieIcedApp { selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, + vector_drag_preview: None, }); } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index e1cb666..70020f8 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -95,6 +95,8 @@ struct OverlayProgram { selected_vector_angle: f32, /// Which vector edit handle is active (hovered or being dragged). vector_edit_handle: hcie_engine_api::VectorEditHandle, + /// Transformed vector shape being previewed during a drag (not yet committed). + vector_drag_preview: Option, } /// Overlay state — tracks hover and cursor position for crosshair. @@ -525,6 +527,142 @@ impl OverlayProgram { }, ); } + + /// Rotates a canvas point around a center by the given angle. + fn rotate_point(&self, px: f32, py: f32, cx: f32, cy: f32, angle: f32) -> (f32, f32) { + let dx = px - cx; + let dy = py - cy; + let cos = angle.cos(); + let sin = angle.sin(); + (cx + dx * cos - dy * sin, cy + dx * sin + dy * cos) + } + + /// Draws a small cursor glyph next to the hovered vector handle. + /// + /// Purpose: Gives immediate visual feedback for the operation that will + /// happen on drag (move or rotate) using simple arrows that are visible + /// even when the platform cursor theme is subtle. + fn draw_vector_handle_cursor_hint( + &self, + frame: &mut Frame, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + angle: f32, + origin_x: f32, + origin_y: f32, + hovered: hcie_engine_api::VectorEditHandle, + ) { + use hcie_engine_api::VectorEditHandle as H; + + let cx = (x1 + x2) * 0.5; + let cy = (y1 + y2) * 0.5; + let (hx, hy) = match hovered { + H::Move => (cx, cy), + H::Rotate => { + let offset = crate::vector_edit::ROTATE_OFFSET_PX / self.zoom.max(0.000_1); + self.rotate_point(cx, y1 - offset, cx, cy, angle) + } + _ => return, + }; + let sx = origin_x + hx * self.zoom; + let sy = origin_y + hy * self.zoom; + + let color = iced::Color::from_rgb(1.0, 0.9, 0.2); + let arrow_len = 10.0f32; + let head_len = 4.0f32; + + if hovered == H::Move { + // Four-way move arrows around the handle point. + let directions = [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)]; + for (dx, dy) in directions { + let start = Point::new(sx - dx * arrow_len, sy - dy * arrow_len); + let end = Point::new(sx + dx * arrow_len, sy + dy * arrow_len); + let shaft = Path::line(start, end); + frame.stroke( + &shaft, + Stroke { + style: canvas::stroke::Style::Solid(color), + width: 1.5, + ..Default::default() + }, + ); + // Arrow head (two short diagonal lines) + let head1 = Point::new( + end.x + (dx * 0.0 - dy) * head_len * 0.7, + end.y + (dx + dy * 0.0) * head_len * 0.7, + ); + let head2 = Point::new( + end.x + (dx * 0.0 + dy) * head_len * 0.7, + end.y + (-dx + dy * 0.0) * head_len * 0.7, + ); + frame.stroke( + &Path::line(end, head1), + Stroke { + style: canvas::stroke::Style::Solid(color), + width: 1.5, + ..Default::default() + }, + ); + frame.stroke( + &Path::line(end, head2), + Stroke { + style: canvas::stroke::Style::Solid(color), + width: 1.5, + ..Default::default() + }, + ); + } + } else if hovered == H::Rotate { + // Circular arrow approximated by a short arc. + let radius = 9.0f32; + let arc_points: Vec = (0..=16) + .map(|i| { + let a = std::f32::consts::PI * 1.25 + + (std::f32::consts::PI * 0.75) * (i as f32 / 16.0); + Point::new(sx + radius * a.cos(), sy + radius * a.sin()) + }) + .collect(); + let arc = Path::new(|b| { + if let Some(first) = arc_points.first() { + b.move_to(*first); + for p in arc_points.iter().skip(1) { + b.line_to(*p); + } + } + }); + frame.stroke( + &arc, + Stroke { + style: canvas::stroke::Style::Solid(color), + width: 1.5, + ..Default::default() + }, + ); + // Arrow head at the end of the arc. + let end_angle = std::f32::consts::PI * 2.0; + let tip = Point::new(sx + radius * end_angle.cos(), sy + radius * end_angle.sin()); + let head1 = Point::new(tip.x - 3.0, tip.y - 4.0); + let head2 = Point::new(tip.x + 4.0, tip.y - 3.0); + frame.stroke( + &Path::line(tip, head1), + Stroke { + style: canvas::stroke::Style::Solid(color), + width: 1.5, + ..Default::default() + }, + ); + frame.stroke( + &Path::line(tip, head2), + Stroke { + style: canvas::stroke::Style::Solid(color), + width: 1.5, + ..Default::default() + }, + ); + } + } } impl canvas::Program for OverlayProgram { @@ -653,7 +791,7 @@ impl canvas::Program for OverlayProgram { // shape outline, not just a bounding box). if let Some(((x0, y0), (x1, y1))) = self.vector_draw { let stroke_style = Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 0.8, 0.0)), + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.1, 0.8, 1.0)), width: 2.0 / self.zoom.max(1.0), line_dash: canvas::LineDash { segments: &[5.0, 5.0], @@ -662,16 +800,24 @@ impl canvas::Program for OverlayProgram { ..Default::default() }; + // Normalized bounding box (matching engine's normalized_rect). + let left = x0.min(x1); + let right = x0.max(x1); + let top = y0.min(y1); + let bottom = y0.max(y1); + let bw = right - left; + let bh = bottom - top; + match self.active_tool { hcie_engine_api::Tool::VectorCircle => { // Ellipse preview using the bounding box center + radii. - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let rx = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let ry = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom; + // Engine: cx=(left+right)/2, cy=(top+bottom)/2, rx=(right-left)/2, ry=(bottom-top)/2 + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let rx = (bw / 2.0).max(1.0) * self.zoom; + let ry = (bh / 2.0).max(1.0) * self.zoom; + let segments = ((rx + ry).max(1.0) * 6.0).max(32.0).ceil() as i32; let ellipse_path = Path::new(|b| { - // Approximate ellipse with 48 segments. - let segments = 48; for i in 0..=segments { let t = i as f32 / segments as f32 * std::f32::consts::TAU; let px = cx + rx * t.cos(); @@ -694,17 +840,18 @@ impl canvas::Program for OverlayProgram { frame.stroke(&line_path, stroke_style); } hcie_engine_api::Tool::VectorStar => { - // Star preview using user's configured point count. - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let r_out = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let r_in = r_out * 0.5; + // Star preview — engine: outer_r = min(bw, bh)/2, inner_r = inner_radius * outer_r + // inner_radius defaults to 0.5 (VectorConfig), configurable per shape. + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let outer_r = (bw.min(bh) / 2.0).max(1.0) * self.zoom; + let inner_r = outer_r * 0.5; // matches VectorConfig::star_inner_radius default let points = self.star_points.max(3); let star_path = Path::new(|b| { for i in 0..(points * 2) { - let t = (i as f32) * std::f32::consts::PI / points as f32 - - std::f32::consts::FRAC_PI_2; - let r = if i % 2 == 0 { r_out } else { r_in }; + let t = std::f32::consts::PI * 2.0 * i as f32 / (points * 2) as f32 + - std::f32::consts::PI / 2.0; + let r = if i % 2 == 0 { outer_r } else { inner_r }; let px = cx + r * t.cos(); let py = cy + r * t.sin(); if i == 0 { @@ -718,15 +865,15 @@ impl canvas::Program for OverlayProgram { frame.stroke(&star_path, stroke_style); } hcie_engine_api::Tool::VectorPolygon => { - // Polygon preview using user's configured side count. - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let r = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; + // Polygon preview — engine: r = min(bw, bh)/2 + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let r = (bw.min(bh) / 2.0).max(1.0) * self.zoom; let sides = self.polygon_sides.max(3); let poly_path = Path::new(|b| { for i in 0..sides { - let t = (i as f32) * std::f32::consts::TAU / sides as f32 - - std::f32::consts::FRAC_PI_2; + let t = std::f32::consts::PI * 2.0 * i as f32 / sides as f32 + - std::f32::consts::PI / 2.0; let px = cx + r * t.cos(); let py = cy + r * t.sin(); if i == 0 { @@ -740,65 +887,64 @@ impl canvas::Program for OverlayProgram { frame.stroke(&poly_path, stroke_style); } hcie_engine_api::Tool::VectorArrow => { - // Arrow: triangle head at the end, shaft line from start to end. - let p0 = Point::new(origin_x + x0 * self.zoom, origin_y + y0 * self.zoom); - let p1 = Point::new(origin_x + x1 * self.zoom, origin_y + y1 * self.zoom); - let shaft_path = Path::line(p0, p1); - frame.stroke(&shaft_path, stroke_style); - // Triangle head at the end point. + // Arrow — engine uses stroke-proportional thickness and head length. + // arrow_thickness = stroke * 1.8, head_len = arrow_thickness * 3 let dx = x1 - x0; let dy = y1 - y0; let len = (dx * dx + dy * dy).sqrt().max(1.0); - let nx = dx / len; - let ny = dy / len; - let head_len = len * 0.25; - let head_w = len * 0.18; - let hx = origin_x + x1 * self.zoom; - let hy = origin_y + y1 * self.zoom; - let base_x = origin_x + (x1 - nx * head_len) * self.zoom; - let base_y = origin_y + (y1 - ny * head_len) * self.zoom; - let tip = Point::new(hx, hy); - let left = Point::new(base_x - ny * head_w, base_y + nx * head_w); - let right = Point::new(base_x + ny * head_w, base_y - nx * head_w); - let head_path = Path::new(|b| { - b.move_to(tip); - b.line_to(left); - b.line_to(right); - b.close(); - }); - frame.stroke(&head_path, stroke_style); + let head_angle = dy.atan2(dx); + let arrow_angle = std::f32::consts::PI / 6.0; + let arrow_thickness = self.zoom; // stroke width in canvas space + let head_len = arrow_thickness * 3.0; + + let rx2 = origin_x + x1 * self.zoom; + let ry2 = origin_y + y1 * self.zoom; + let rx3 = rx2 - head_len * (head_angle + arrow_angle).cos(); + let ry3 = ry2 - head_len * (head_angle + arrow_angle).sin(); + let rx4 = rx2 - head_len * (head_angle - arrow_angle).cos(); + let ry4 = ry2 - head_len * (head_angle - arrow_angle).sin(); + + let p0 = Point::new(origin_x + x0 * self.zoom, origin_y + y0 * self.zoom); + let p1 = Point::new(rx2, ry2); + let p3 = Point::new(rx3, ry3); + let p4 = Point::new(rx4, ry4); + // Shaft line + let shaft_path = Path::line(p0, p1); + frame.stroke(&shaft_path, stroke_style); + // Head lines + let head1 = Path::line(p1, p3); + frame.stroke(&head1, stroke_style); + let head2 = Path::line(p1, p4); + frame.stroke(&head2, stroke_style); } hcie_engine_api::Tool::VectorRhombus => { - // Diamond shape: top, right, bottom, left of bounding box. - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let hw = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let hh = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom; + // Diamond shape — matches engine: (cx,top), (right,cy), (cx,bottom), (left,cy) + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; let rhombus_path = Path::new(|b| { - b.move_to(Point::new(cx, cy - hh)); // top - b.line_to(Point::new(cx + hw, cy)); // right - b.line_to(Point::new(cx, cy + hh)); // bottom - b.line_to(Point::new(cx - hw, cy)); // left + b.move_to(Point::new(cx, origin_y + top * self.zoom)); + b.line_to(Point::new(origin_x + right * self.zoom, cy)); + b.line_to(Point::new(cx, origin_y + bottom * self.zoom)); + b.line_to(Point::new(origin_x + left * self.zoom, cy)); b.close(); }); frame.stroke(&rhombus_path, stroke_style); } hcie_engine_api::Tool::VectorCylinder => { - // Cylinder: ellipse at top and bottom, vertical lines connecting them. - let left = origin_x + x0.min(x1) * self.zoom; - let right = origin_x + x0.max(x1) * self.zoom; - let top = origin_y + y0.min(y1) * self.zoom; - let bottom = origin_y + y0.max(y1) * self.zoom; - let hw = (right - left) / 2.0; + // Cylinder — engine: ry = (bottom - top) * 0.1 + let left_scr = origin_x + left * self.zoom; + let right_scr = origin_x + right * self.zoom; + let top_scr = origin_y + top * self.zoom; + let bottom_scr = origin_y + bottom * self.zoom; + let hw = (right_scr - left_scr) / 2.0; let ellipse_rx = hw; - let ellipse_ry = hw * 0.3; + let ellipse_ry = (bottom - top) * 0.1 * self.zoom; let segments = 36; - // Top ellipse - let top_cy = top + ellipse_ry; + let top_cy = top_scr + ellipse_ry; let top_ellipse = Path::new(|b| { for i in 0..=segments { let t = i as f32 / segments as f32 * std::f32::consts::TAU; - let px = left + hw + ellipse_rx * t.cos(); + let px = left_scr + hw + ellipse_rx * t.cos(); let py = top_cy + ellipse_ry * t.sin(); if i == 0 { b.move_to(Point::new(px, py)); @@ -809,12 +955,11 @@ impl canvas::Program for OverlayProgram { b.close(); }); frame.stroke(&top_ellipse, stroke_style); - // Bottom ellipse - let bot_cy = bottom - ellipse_ry; + let bot_cy = bottom_scr - ellipse_ry; let bot_ellipse = Path::new(|b| { for i in 0..=segments { let t = i as f32 / segments as f32 * std::f32::consts::TAU; - let px = left + hw + ellipse_rx * t.cos(); + let px = left_scr + hw + ellipse_rx * t.cos(); let py = bot_cy + ellipse_ry * t.sin(); if i == 0 { b.move_to(Point::new(px, py)); @@ -825,48 +970,54 @@ impl canvas::Program for OverlayProgram { b.close(); }); frame.stroke(&bot_ellipse, stroke_style); - // Left vertical line let left_line = - Path::line(Point::new(left, top_cy), Point::new(left, bot_cy)); + Path::line(Point::new(left_scr, top_cy), Point::new(left_scr, bot_cy)); frame.stroke(&left_line, stroke_style); - // Right vertical line - let right_line = - Path::line(Point::new(right, top_cy), Point::new(right, bot_cy)); + let right_line = Path::line( + Point::new(right_scr, top_cy), + Point::new(right_scr, bot_cy), + ); frame.stroke(&right_line, stroke_style); } hcie_engine_api::Tool::VectorHeart => { - // Heart: two arcs at top meeting at bottom point. - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let top = origin_y + y0.min(y1) * self.zoom; - let bottom = origin_y + y0.max(y1) * self.zoom; - let hw = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let hh = bottom - top; - let cp_off = hw * 0.55; + // Heart — engine uses parametric formula: + // x = cx + hw * (16*sin³t)/16 + // y = cy - hh * (13cos(t) - 5cos(2t) - 2cos(3t) - cos(4t))/16 + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let hw = (bw / 2.0).max(1.0) * self.zoom; + let hh = (bh / 2.0).max(1.0) * self.zoom; + let steps = 48; let heart_path = Path::new(|b| { - b.move_to(Point::new(cx, top + hh * 0.35)); - // Right curve - b.bezier_curve_to( - Point::new(cx + cp_off, top - hh * 0.15), - Point::new(cx + hw, top + hh * 0.1), - Point::new(cx, bottom), - ); - // Left curve - b.bezier_curve_to( - Point::new(cx - hw, top + hh * 0.1), - Point::new(cx - cp_off, top - hh * 0.15), - Point::new(cx, top + hh * 0.35), - ); + for i in 0..=steps { + let t = std::f32::consts::TAU * i as f32 / steps as f32; + let px = cx + hw * (16.0 * t.sin().powi(3)) / 16.0; + let py = cy + - hh * (13.0 * t.cos() + - 5.0 * (2.0 * t).cos() + - 2.0 * (3.0 * t).cos() + - (4.0 * t).cos()) + / 16.0; + if i == 0 { + b.move_to(Point::new(px, py)); + } else { + b.line_to(Point::new(px, py)); + } + } b.close(); }); frame.stroke(&heart_path, stroke_style); } hcie_engine_api::Tool::VectorBubble => { - // Bubble: ellipse with a small triangle tail at bottom-left. - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let rx = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let ry = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom; + // Bubble — engine: ellipse body + triangular tail at bottom-left. + // Tail: inner at (cx+rx*0.2, cy+ry*0.8), tip at (cx-rx*0.6, cy+ry*1.4), + // outer at (cx-rx*0.6, cy+ry*0.8) + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let rx = (bw / 2.0).max(1.0) * self.zoom; + let ry = (bh / 2.0).max(1.0) * self.zoom; let segments = 48; + // Full ellipse body let bubble_path = Path::new(|b| { for i in 0..=segments { let t = i as f32 / segments as f32 * std::f32::consts::TAU; @@ -881,89 +1032,51 @@ impl canvas::Program for OverlayProgram { b.close(); }); frame.stroke(&bubble_path, stroke_style); - // Triangle tail at bottom-left. - let tail_base_x = cx - rx * 0.35; - let tail_base_y = cy + ry * 0.7; - let tail_tip_x = cx - rx * 0.6; - let tail_tip_y = cy + ry * 1.15; + // Tail triangle + let tail_inner = Point::new(cx + rx * 0.2, cy + ry * 0.8); + let tail_tip = Point::new(cx - rx * 0.6, cy + ry * 1.4); + let tail_outer = Point::new(cx - rx * 0.6, cy + ry * 0.8); let tail_path = Path::new(|b| { - b.move_to(Point::new(tail_base_x - rx * 0.15, tail_base_y)); - b.line_to(Point::new(tail_tip_x, tail_tip_y)); - b.line_to(Point::new(tail_base_x + rx * 0.15, tail_base_y)); + b.move_to(tail_inner); + b.line_to(tail_tip); + b.line_to(tail_outer); b.close(); }); frame.stroke(&tail_path, stroke_style); } hcie_engine_api::Tool::VectorGear => { - // Gear: circle with teeth (small rectangles around perimeter). - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let r_out = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let r_in = r_out * 0.78; + // Gear — engine: outer_r = r_base*0.9, inner_r = r_base*0.6, hole_r = r_base*0.25 + // r_base = min(bw, bh)/2 + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let r_base = (bw.min(bh) / 2.0).max(1.0) * self.zoom; + let outer_r = r_base * 0.9; + let inner_r = r_base * 0.6; + let hole_r = r_base * 0.25; let teeth = 8; - let segments = 48; let gear_path = Path::new(|b| { - for i in 0..teeth { - let a0 = (i as f32) * std::f32::consts::TAU / teeth as f32; - let a1 = (i as f32 + 0.35) * std::f32::consts::TAU / teeth as f32; - let a2 = (i as f32 + 0.65) * std::f32::consts::TAU / teeth as f32; - let a3 = ((i + 1) as f32) * std::f32::consts::TAU / teeth as f32; - let p0 = Point::new(cx + r_in * a0.cos(), cy + r_in * a0.sin()); - let p1 = Point::new(cx + r_out * a1.cos(), cy + r_out * a1.sin()); - let p2 = Point::new(cx + r_out * a2.cos(), cy + r_out * a2.sin()); - let p3 = Point::new(cx + r_in * a3.cos(), cy + r_in * a3.sin()); + for i in 0..(teeth * 2) { + let a = std::f32::consts::PI * 2.0 * i as f32 / (teeth * 2) as f32 + - std::f32::consts::PI / 2.0; + let r = if i % 2 == 0 { outer_r } else { inner_r }; + let px = cx + r * a.cos(); + let py = cy + r * a.sin(); if i == 0 { - b.move_to(p0); + b.move_to(Point::new(px, py)); } else { - b.line_to(p0); + b.line_to(Point::new(px, py)); } - b.line_to(p1); - b.line_to(p2); - b.line_to(p3); } b.close(); }); frame.stroke(&gear_path, stroke_style); - } - hcie_engine_api::Tool::VectorCross => { - // Cross: plus sign shape (horizontal + vertical bars). - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let hw = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let hh = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom; - let arm = hw * 0.3; - let cross_path = Path::new(|b| { - // Horizontal bar - b.move_to(Point::new(cx - hw, cy - arm)); - b.line_to(Point::new(cx + hw, cy - arm)); - b.line_to(Point::new(cx + hw, cy + arm)); - b.line_to(Point::new(cx - hw, cy + arm)); - b.close(); - }); - frame.stroke(&cross_path, stroke_style); - let cross_path2 = Path::new(|b| { - // Vertical bar - b.move_to(Point::new(cx - arm, cy - hh)); - b.line_to(Point::new(cx + arm, cy - hh)); - b.line_to(Point::new(cx + arm, cy + hh)); - b.line_to(Point::new(cx - arm, cy + hh)); - b.close(); - }); - frame.stroke(&cross_path2, stroke_style); - } - hcie_engine_api::Tool::VectorCrescent => { - // Crescent: outer and inner arcs forming a moon shape. - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let r = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let segments = 48; - let inner_offset = r * 0.35; - let crescent_path = Path::new(|b| { - // Outer arc (full circle) + // Center hole + let hole_path = Path::new(|b| { + let segments = 36; for i in 0..=segments { let t = i as f32 / segments as f32 * std::f32::consts::TAU; - let px = cx + r * t.cos(); - let py = cy + r * t.sin(); + let px = cx + hole_r * t.cos(); + let py = cy + hole_r * t.sin(); if i == 0 { b.move_to(Point::new(px, py)); } else { @@ -972,78 +1085,151 @@ impl canvas::Program for OverlayProgram { } b.close(); }); + frame.stroke(&hole_path, stroke_style); + } + hcie_engine_api::Tool::VectorCross => { + // Cross — engine: plus sign, arm = min(bw, bh) * 0.25 + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let arm = (bw.min(bh) * 0.25).max(1.0) * self.zoom; + let cross_path = Path::new(|b| { + let left_scr = origin_x + left * self.zoom; + let right_scr = origin_x + right * self.zoom; + let top_scr = origin_y + top * self.zoom; + let bottom_scr = origin_y + bottom * self.zoom; + // Top arm + b.move_to(Point::new(cx - arm, top_scr)); + b.line_to(Point::new(cx + arm, top_scr)); + b.line_to(Point::new(cx + arm, cy - arm)); + // Right arm + b.line_to(Point::new(right_scr, cy - arm)); + b.line_to(Point::new(right_scr, cy + arm)); + // Bottom arm + b.line_to(Point::new(cx + arm, cy + arm)); + b.line_to(Point::new(cx + arm, bottom_scr)); + b.line_to(Point::new(cx - arm, bottom_scr)); + // Left arm + b.line_to(Point::new(cx - arm, cy + arm)); + b.line_to(Point::new(left_scr, cy + arm)); + b.line_to(Point::new(left_scr, cy - arm)); + b.line_to(Point::new(cx - arm, cy - arm)); + b.close(); + }); + frame.stroke(&cross_path, stroke_style); + } + hcie_engine_api::Tool::VectorCrescent => { + // Crescent — engine: outer arc from -0.2π to 1.2π, inner arc reversed. + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let rx = (bw / 2.0).max(1.0) * self.zoom; + let ry = (bh / 2.0).max(1.0) * self.zoom; + let segments = 48; + let start_angle = -std::f32::consts::PI * 0.2; + let end_angle = std::f32::consts::PI * 1.2; + // Outer arc + let crescent_path = Path::new(|b| { + for i in 0..=segments { + let a = start_angle + + (end_angle - start_angle) * i as f32 / segments as f32; + let px = cx + rx * a.cos(); + let py = cy + ry * a.sin(); + if i == 0 { + b.move_to(Point::new(px, py)); + } else { + b.line_to(Point::new(px, py)); + } + } + // Inner arc (reversed) with morphing to close at tips + let inner_cx = cx + rx * 0.4; + let inner_rx = rx * 0.7; + let inner_ry = ry * 0.7; + let p_start = Point::new( + cx + rx * start_angle.cos(), + cy + ry * start_angle.sin(), + ); + let inner_p_start = Point::new( + inner_cx + inner_rx * start_angle.cos(), + cy + inner_ry * start_angle.sin(), + ); + let diff_start_x = p_start.x - inner_p_start.x; + let diff_start_y = p_start.y - inner_p_start.y; + let p_end = + Point::new(cx + rx * end_angle.cos(), cy + ry * end_angle.sin()); + let inner_p_end = Point::new( + inner_cx + inner_rx * end_angle.cos(), + cy + inner_ry * end_angle.sin(), + ); + let diff_end_x = p_end.x - inner_p_end.x; + let diff_end_y = p_end.y - inner_p_end.y; + for i in (0..=segments).rev() { + let t = i as f32 / segments as f32; + let a = start_angle + (end_angle - start_angle) * t; + let raw_x = inner_cx + inner_rx * a.cos(); + let raw_y = cy + inner_ry * a.sin(); + let offset_x = (1.0 - t) * diff_start_x + t * diff_end_x; + let offset_y = (1.0 - t) * diff_start_y + t * diff_end_y; + b.line_to(Point::new(raw_x + offset_x, raw_y + offset_y)); + } + b.close(); + }); frame.stroke(&crescent_path, stroke_style); - // Inner arc (smaller, offset to create crescent) - let inner_path = Path::new(|b| { - for i in 0..=segments { - let t = i as f32 / segments as f32 * std::f32::consts::TAU; - let px = cx + inner_offset + (r * 0.7) * t.cos(); - let py = cy + (r * 0.7) * t.sin(); - if i == 0 { - b.move_to(Point::new(px, py)); - } else { - b.line_to(Point::new(px, py)); - } - } - b.close(); - }); - frame.stroke(&inner_path, stroke_style); } hcie_engine_api::Tool::VectorBolt => { - // Bolt: zigzag lightning bolt shape. - let left = origin_x + x0.min(x1) * self.zoom; - let right = origin_x + x0.max(x1) * self.zoom; - let top = origin_y + y0.min(y1) * self.zoom; - let bottom = origin_y + y0.max(y1) * self.zoom; - let hw = (right - left) / 2.0; + // Bolt — engine: classic top-right to bottom-left zigzag lightning bolt. + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let nx = (bw * 0.18).max(1.0) * self.zoom; let bolt_path = Path::new(|b| { - b.move_to(Point::new(left + hw * 0.4, top)); - b.line_to(Point::new(left + hw * 0.8, top)); - b.line_to(Point::new(left + hw * 0.55, top + (bottom - top) * 0.42)); - b.line_to(Point::new(left + hw * 0.85, top + (bottom - top) * 0.42)); - b.line_to(Point::new(left + hw * 0.25, bottom)); - b.line_to(Point::new(left + hw * 0.45, top + (bottom - top) * 0.55)); - b.line_to(Point::new(left + hw * 0.15, top + (bottom - top) * 0.55)); + b.move_to(Point::new(cx + nx, origin_y + top * self.zoom)); + b.line_to(Point::new( + origin_x + right * self.zoom, + origin_y + (top + bh * 0.28) * self.zoom, + )); + b.line_to(Point::new(cx - nx, cy - bh * 0.05 * self.zoom)); + b.line_to(Point::new(cx + nx, cy + bh * 0.05 * self.zoom)); + b.line_to(Point::new( + origin_x + left * self.zoom, + origin_y + (bottom - bh * 0.28) * self.zoom, + )); + b.line_to(Point::new(cx - nx, origin_y + bottom * self.zoom)); b.close(); }); frame.stroke(&bolt_path, stroke_style); } hcie_engine_api::Tool::VectorArrow4 => { - // 4-way arrow: up/down/left/right from center. - let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; - let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; - let hw = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; - let hh = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom; - let arm = hw * 0.25; + // Arrow4 — engine: cross-shaped body with 4 arrowheads. + let cx = origin_x + ((left + right) / 2.0) * self.zoom; + let cy = origin_y + ((top + bottom) / 2.0) * self.zoom; + let arm_w = (bw * 0.125).max(1.0) * self.zoom; + let arm_h = (bh * 0.125).max(1.0) * self.zoom; + let head_extend_x = (bw * 0.5 * 0.3) * self.zoom; + let head_extend_y = (bh * 0.5 * 0.3) * self.zoom; + let top_scr = origin_y + top * self.zoom; + let bottom_scr = origin_y + bottom * self.zoom; + let left_scr = origin_x + left * self.zoom; + let right_scr = origin_x + right * self.zoom; let arrow4_path = Path::new(|b| { - // Up arrow - b.move_to(Point::new(cx - arm, cy)); - b.line_to(Point::new(cx - arm, cy - hh + arm)); - b.line_to(Point::new(cx - arm * 2.0, cy - hh + arm)); - b.line_to(Point::new(cx, cy - hh)); - b.line_to(Point::new(cx + arm * 2.0, cy - hh + arm)); - b.line_to(Point::new(cx + arm, cy - hh + arm)); - b.line_to(Point::new(cx + arm, cy)); - // Right arrow - b.line_to(Point::new(cx + hw - arm, cy)); - b.line_to(Point::new(cx + hw - arm, cy - arm * 2.0)); - b.line_to(Point::new(cx + hw, cy)); - b.line_to(Point::new(cx + hw - arm, cy + arm * 2.0)); - b.line_to(Point::new(cx + hw - arm, cy)); - // Down arrow - b.line_to(Point::new(cx + arm, cy)); - b.line_to(Point::new(cx + arm, cy + hh - arm)); - b.line_to(Point::new(cx + arm * 2.0, cy + hh - arm)); - b.line_to(Point::new(cx, cy + hh)); - b.line_to(Point::new(cx - arm * 2.0, cy + hh - arm)); - b.line_to(Point::new(cx - arm, cy + hh - arm)); - b.line_to(Point::new(cx - arm, cy)); - // Left arrow - b.line_to(Point::new(cx - hw + arm, cy)); - b.line_to(Point::new(cx - hw + arm, cy - arm * 2.0)); - b.line_to(Point::new(cx - hw, cy)); - b.line_to(Point::new(cx - hw + arm, cy + arm * 2.0)); - b.line_to(Point::new(cx - hw + arm, cy)); + // Up arm with arrowhead + b.move_to(Point::new(cx - arm_w, cy - arm_h)); + b.line_to(Point::new(cx - arm_w, top_scr + head_extend_y)); + b.line_to(Point::new(cx, top_scr)); + b.line_to(Point::new(cx + arm_w, top_scr + head_extend_y)); + b.line_to(Point::new(cx + arm_w, cy - arm_h)); + // Right arm with arrowhead + b.line_to(Point::new(right_scr - head_extend_x, cy - arm_h)); + b.line_to(Point::new(right_scr, cy)); + b.line_to(Point::new(right_scr - head_extend_x, cy + arm_h)); + b.line_to(Point::new(cx + arm_w, cy + arm_h)); + // Down arm with arrowhead + b.line_to(Point::new(cx + arm_w, bottom_scr - head_extend_y)); + b.line_to(Point::new(cx, bottom_scr)); + b.line_to(Point::new(cx - arm_w, bottom_scr - head_extend_y)); + b.line_to(Point::new(cx - arm_w, cy + arm_h)); + // Left arm with arrowhead + b.line_to(Point::new(left_scr + head_extend_x, cy + arm_h)); + b.line_to(Point::new(left_scr, cy)); + b.line_to(Point::new(left_scr + head_extend_x, cy - arm_h)); + b.line_to(Point::new(cx - arm_w, cy - arm_h)); b.close(); }); frame.stroke(&arrow4_path, stroke_style); @@ -1110,6 +1296,17 @@ impl canvas::Program for OverlayProgram { origin_y, state.hovered_vector_handle, ); + self.draw_vector_handle_cursor_hint( + &mut frame, + x1, + y1, + x2, + y2, + self.selected_vector_angle, + origin_x, + origin_y, + state.hovered_vector_handle, + ); } // Draw gradient endpoint preview line. @@ -1414,22 +1611,33 @@ pub fn view<'a>( // ── Overlay canvas (selection, vector preview, crosshair, pressure HUD) ────── // Compute selected vector shape bounds for handle rendering. - let (sel_vec_bounds, sel_vec_angle) = if let Some(idx) = doc.selected_vector_shape { - let layer_id = doc.engine.active_layer_id(); - if let Some(shapes) = doc.engine.active_vector_shapes() { - if let Some(shape) = shapes.get(idx) { - let (x1, y1, x2, y2) = shape.normalized_bounds(); - let angle = doc.engine.vector_shape_angle(layer_id, idx); - (Some((x1, y1, x2, y2)), angle) + // During a vector transform drag, show the preview shape instead of the + // engine's committed shape so the overlay stays in sync with the pointer + // without paying for per-frame engine rasterization. + let (sel_vec_bounds, sel_vec_angle, vector_preview) = + if let Some(preview) = doc.vector_drag_preview.as_ref() { + let (x1, y1, x2, y2) = preview.normalized_bounds(); + ( + Some((x1, y1, x2, y2)), + preview.angle(), + Some(preview.clone()), + ) + } else if let Some(idx) = doc.selected_vector_shape { + let layer_id = doc.engine.active_layer_id(); + if let Some(shapes) = doc.engine.active_vector_shapes() { + if let Some(shape) = shapes.get(idx) { + let (x1, y1, x2, y2) = shape.normalized_bounds(); + let angle = doc.engine.vector_shape_angle(layer_id, idx); + (Some((x1, y1, x2, y2)), angle, None) + } else { + (None, 0.0, None) + } } else { - (None, 0.0) + (None, 0.0, None) } } else { - (None, 0.0) - } - } else { - (None, 0.0) - }; + (None, 0.0, None) + }; let overlay_program = OverlayProgram { engine_w, @@ -1458,6 +1666,7 @@ pub fn view<'a>( selected_vector_bounds: sel_vec_bounds, selected_vector_angle: sel_vec_angle, vector_edit_handle: doc.vector_edit_handle, + vector_drag_preview: vector_preview, }; let overlay_canvas = canvas(overlay_program) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs index b0afe83..b888119 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs @@ -641,7 +641,13 @@ impl CanvasShaderPipeline { /// * `mask` — selection mask bytes (1 byte per pixel, >128 = selected) /// * `canvas_w` — mask width in pixels /// * `canvas_h` — mask height in pixels - fn upload_selection_mask(&self, queue: &wgpu::Queue, mask: &[u8], canvas_w: u32, canvas_h: u32) { + fn upload_selection_mask( + &self, + queue: &wgpu::Queue, + mask: &[u8], + canvas_w: u32, + canvas_h: u32, + ) { if mask.len() != (canvas_w * canvas_h) as usize { log::warn!( "[CanvasShaderPipeline::upload_selection_mask] Size mismatch: mask={} but canvas={}×{}={}", @@ -821,7 +827,11 @@ impl shader::Primitive for CanvasShaderPrimitive { viewport_h, checker_size: CHECKER_SQUARE, anim_time: self.anim_time, - has_selection: if self.selection_mask.is_some() { 1.0 } else { 0.0 }, + has_selection: if self.selection_mask.is_some() { + 1.0 + } else { + 0.0 + }, quick_mask: if self.quick_mask { 1.0 } else { 0.0 }, }; 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 d6f68fb..3476dbc 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 @@ -275,9 +275,9 @@ pub fn popup_view( color, output: WheelOutput::Popup, }) - .width(Length::Fixed(220.0)) - .height(Length::Fixed(220.0)) - .into(), + .width(Length::Fixed(220.0)) + .height(Length::Fixed(220.0)) + .into(), _ => hsl_popup_section(color, h, s, l), }; @@ -584,18 +584,12 @@ impl canvas::Program for ColorWheel { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { state.dragging = true; - return ( - canvas::event::Status::Captured, - Some(self.message(color)), - ); + return (canvas::event::Status::Captured, Some(self.message(color))); } } canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { - return ( - canvas::event::Status::Captured, - Some(self.message(color)), - ); + return (canvas::event::Status::Captured, Some(self.message(color))); } } canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { @@ -849,9 +843,7 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, fg_color: &'a [u8; 4]) -> Elemen .width(Length::Fill) .height(Length::Fixed(220.0)); - column![wheel, readout] - .spacing(3) - .into() + column![wheel, readout].spacing(3).into() } /// Build the HSL sliders view (tab H). diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/auto_hide.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/auto_hide.rs index 85f4fd2..916d695 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/auto_hide.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/auto_hide.rs @@ -5,8 +5,9 @@ use crate::app::Message; use crate::dock::state::PaneType; +use crate::panels::styles; use crate::theme::ThemeColors; -use iced::widget::{button, column, container, row, text, tooltip}; +use iced::widget::{button, column, container, row, text}; use iced::{Element, Length}; /// Renders the fixed right auto-hide rail outside PaneGrid. @@ -17,15 +18,16 @@ pub fn right_rail<'a>( let mut tabs = column![].spacing(3).padding([4, 2]).width(36); for panel in panels { let tab = button(text(panel.icon_glyph()).size(16)) - .on_press(Message::AutoHideActivate(panel)) - .width(32) - .height(32) - .padding(6) - .style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status)); - tabs = tabs.push(tooltip( + .on_press(Message::AutoHideActivate(panel)) + .width(32) + .height(32) + .padding(6) + .style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status)); + tabs = tabs.push(styles::balloon_tooltip( tab, - text(panel.label()).size(11), - tooltip::Position::Left, + panel.label(), + iced::widget::tooltip::Position::Left, + colors, )); } container(tabs) @@ -48,15 +50,16 @@ pub fn overlay<'a>( text(panel.icon_glyph()).size(14).color(colors.accent), text(panel.label()).size(12).color(colors.text_primary), iced::widget::Space::with_width(Length::Fill), - tooltip( + styles::balloon_tooltip( button(text("●").size(10)) .on_press(Message::AutoHideRestore(panel)) .padding([3, 6]) .style( move |_theme, status| crate::dock::view::chrome_button_style(colors, status) ), - text("Pin panel open").size(11), - tooltip::Position::Left + "Pin panel open", + iced::widget::tooltip::Position::Left, + colors, ), button(text("×").size(14)) .on_press(Message::AutoHideDismiss) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/floating.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/floating.rs index 218674e..8e0950c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/floating.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/floating.rs @@ -7,8 +7,9 @@ use crate::app::Message; use crate::dock::manager::DockRect; use crate::dock::persistence::PersistedFloatingPanel; use crate::dock::state::PaneType; +use crate::panels::styles; use crate::theme::ThemeColors; -use iced::widget::{button, column, container, mouse_area, row, text, tooltip, Space}; +use iced::widget::{button, column, container, mouse_area, row, text, Space}; use iced::{Element, Length, Point}; pub const DEFAULT_WIDTH: f32 = 320.0; @@ -59,20 +60,23 @@ pub fn card<'a>( }; let header = row![ title, - tooltip( + styles::balloon_tooltip( control("−", Message::FloatingAutoHide(panel)), - text("Minimize to side rail").size(11), - tooltip::Position::Bottom + "Minimize to side rail", + iced::widget::tooltip::Position::Bottom, + colors, ), - tooltip( + styles::balloon_tooltip( control("●", Message::FloatingRedock(panel)), - text("Redock panel").size(11), - tooltip::Position::Bottom + "Redock panel", + iced::widget::tooltip::Position::Bottom, + colors, ), - tooltip( + styles::balloon_tooltip( control("×", Message::FloatingClose(panel)), - text("Close panel").size(11), - tooltip::Position::Bottom + "Close panel", + iced::widget::tooltip::Position::Bottom, + colors, ), ] .align_y(iced::Alignment::Center) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/preview.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/preview.rs index ba96e49..9cb1007 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/preview.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/preview.rs @@ -100,15 +100,11 @@ fn outer_edge(point: Point, bounds: Rectangle) -> Option { let thickness = bounds.width.min(bounds.height) / OUTER_THICKNESS_RATIO; if point.x > bounds.x && point.x < bounds.x + thickness { Some(DockEdge::Left) - } else if point.x > bounds.x + bounds.width - thickness - && point.x < bounds.x + bounds.width - { + } else if point.x > bounds.x + bounds.width - thickness && point.x < bounds.x + bounds.width { Some(DockEdge::Right) } else if point.y > bounds.y && point.y < bounds.y + thickness { Some(DockEdge::Top) - } else if point.y > bounds.y + bounds.height - thickness - && point.y < bounds.y + bounds.height - { + } else if point.y > bounds.y + bounds.height - thickness && point.y < bounds.y + bounds.height { Some(DockEdge::Bottom) } else { None @@ -260,8 +256,14 @@ mod tests { #[test] fn outer_edge_uses_iced_dynamic_thickness_and_priority() { let bounds = Rectangle::new(Point::ORIGIN, Size::new(500.0, 250.0)); - assert_eq!(outer_edge(Point::new(9.9, 1.0), bounds), Some(DockEdge::Left)); + assert_eq!( + outer_edge(Point::new(9.9, 1.0), bounds), + Some(DockEdge::Left) + ); assert_eq!(outer_edge(Point::new(10.0, 125.0), bounds), None); - assert_eq!(outer_edge(Point::new(250.0, 9.9), bounds), Some(DockEdge::Top)); + assert_eq!( + outer_edge(Point::new(250.0, 9.9), bounds), + Some(DockEdge::Top) + ); } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs index 40f2d97..923c1cb 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs @@ -303,18 +303,11 @@ fn window_resize_ratio( } /// Converts child constraints to a legal split interval and clamps a proposed ratio. -fn clamp_ratio( - requested: f32, - total: f32, - a: PaneAxisPolicy, - b: PaneAxisPolicy, -) -> f32 { +fn clamp_ratio(requested: f32, total: f32, a: PaneAxisPolicy, b: PaneAxisPolicy) -> f32 { let total = total.max(1.0); let half_spacing = PANE_SPACING / 2.0; - let mut low = ((a.min + half_spacing) / total) - .max(1.0 - (b.max + half_spacing) / total); - let mut high = (1.0 - (b.min + half_spacing) / total) - .min((a.max + half_spacing) / total); + let mut low = ((a.min + half_spacing) / total).max(1.0 - (b.max + half_spacing) / total); + let mut high = (1.0 - (b.min + half_spacing) / total).min((a.max + half_spacing) / total); low = low.clamp(0.01, 0.99); high = high.clamp(0.01, 0.99); if low > high { @@ -362,9 +355,7 @@ fn split_extent(extent: Size, axis: Axis, ratio: f32) -> (Size, Size) { ) } Axis::Vertical => { - let a = (extent.width * ratio - PANE_SPACING / 2.0) - .max(0.0) - .round(); + let a = (extent.width * ratio - PANE_SPACING / 2.0).max(0.0).round(); ( Size::new(a, extent.height), Size::new((extent.width - a - PANE_SPACING).max(0.0), extent.height), @@ -391,7 +382,10 @@ fn extent_for_axis(size: Size, axis: Axis) -> f32 { /// Compares Iced axes without requiring assumptions about their debug representation. fn same_axis(a: Axis, b: Axis) -> bool { - matches!((a, b), (Axis::Horizontal, Axis::Horizontal) | (Axis::Vertical, Axis::Vertical)) + matches!( + (a, b), + (Axis::Horizontal, Axis::Horizontal) | (Axis::Vertical, Axis::Vertical) + ) } /// Adds maximum extents while preserving an unbounded result. @@ -436,7 +430,10 @@ mod tests { let color = panel_size_policy(PaneType::ColorPicker); assert_eq!((canvas.width.min, canvas.height.min), (320.0, 240.0)); assert!(canvas.width.max.is_infinite()); - assert_eq!((color.width.preferred, color.height.preferred), (280.0, 330.0)); + assert_eq!( + (color.width.preferred, color.height.preferred), + (280.0, 330.0) + ); assert_eq!((color.width.max, color.height.max), (380.0, 380.0)); assert!(canvas.width.grow_priority > color.width.grow_priority); } @@ -457,7 +454,12 @@ mod tests { fn default_and_compact_viewports_have_feasible_bounds() { for size in [Size::new(1206.0, 724.0), Size::new(950.0, 524.0)] { let mut dock = crate::dock::state::DockState::new(); - rebalance_state(&mut dock.pane_grid, size, None, RebalanceMode::InitialDefault); + rebalance_state( + &mut dock.pane_grid, + size, + None, + RebalanceMode::InitialDefault, + ); let regions = dock.pane_grid.layout().pane_regions(PANE_SPACING, size); for (pane, panel) in dock.pane_grid.iter() { let region = regions[pane]; @@ -475,8 +477,16 @@ mod tests { let size = Size::new(300.0, 800.0); rebalance_state(&mut state, size, None, RebalanceMode::InitialDefault); let regions = state.layout().pane_regions(PANE_SPACING, size); - let color = state.iter().find(|(_, panel)| **panel == PaneType::ColorPicker).unwrap().0; - let layers = state.iter().find(|(_, panel)| **panel == PaneType::Layers).unwrap().0; + let color = state + .iter() + .find(|(_, panel)| **panel == PaneType::ColorPicker) + .unwrap() + .0; + let layers = state + .iter() + .find(|(_, panel)| **panel == PaneType::Layers) + .unwrap() + .0; assert!(regions[color].height <= 380.1); assert!(regions[layers].height > regions[color].height); } @@ -491,7 +501,10 @@ mod tests { None, RebalanceMode::StructureChanged, ); - let ratio = state.layout().split_regions(PANE_SPACING, Size::new(1000.0, 700.0))[&split].2; + let ratio = state + .layout() + .split_regions(PANE_SPACING, Size::new(1000.0, 700.0))[&split] + .2; assert!(ratio > 0.6); } @@ -512,7 +525,11 @@ mod tests { let old = Size::new(900.0, 700.0); rebalance_state(&mut state, old, None, RebalanceMode::InitialDefault); let old_regions = state.layout().pane_regions(PANE_SPACING, old); - let layers = *state.iter().find(|(_, panel)| **panel == PaneType::Layers).unwrap().0; + let layers = *state + .iter() + .find(|(_, panel)| **panel == PaneType::Layers) + .unwrap() + .0; let old_utility = old_regions[&layers].width; let new = Size::new(1200.0, 700.0); rebalance_state(&mut state, new, Some(old), RebalanceMode::WindowResize); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs index ee1c7b6..a57db52 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs @@ -728,11 +728,10 @@ impl DockState { self.floating.insert(index, item); return false; }; - let Some((new_pane, _)) = self.pane_grid.split( - iced::widget::pane_grid::Axis::Vertical, - canvas, - panel, - ) else { + let Some((new_pane, _)) = + self.pane_grid + .split(iced::widget::pane_grid::Axis::Vertical, canvas, panel) + else { self.floating.insert(index, item); return false; }; 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 9e11031..8e8bcb2 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 @@ -94,22 +94,21 @@ pub fn dock_view<'a>( .width(Length::Fill) .height(Length::Fill) .into(); - let base: Element<'a, Message> = - if let Some(preview) = dock - .drag - .as_ref() - .and_then(|drag| drag.accepted_preview) - .or_else(|| dock.floating_target.map(|target| target.preview)) - { - iced::widget::Stack::new() - .push(base) - .push(preview_overlay(preview, colors)) - .width(Length::Fill) - .height(Length::Fill) - .into() - } else { - base - }; + let base: Element<'a, Message> = if let Some(preview) = dock + .drag + .as_ref() + .and_then(|drag| drag.accepted_preview) + .or_else(|| dock.floating_target.map(|target| target.preview)) + { + iced::widget::Stack::new() + .push(base) + .push(preview_overlay(preview, colors)) + .width(Length::Fill) + .height(Length::Fill) + .into() + } else { + base + }; if let Some(panel) = dock.active_auto_hide { let overlay = crate::dock::auto_hide::overlay(panel, panel_body(panel, app, colors), colors); @@ -133,11 +132,11 @@ pub fn panel_body<'a>( colors: ThemeColors, ) -> Element<'a, Message> { match &pane_type { - PaneType::Canvas => { - if app.show_welcome { - return crate::dock::welcome::view(colors); - } - // Canvas pane includes document tab bar as its header + PaneType::Canvas => { + if app.show_welcome { + return crate::dock::welcome::view(colors); + } + // Canvas pane includes document tab bar as its header let doc_tab_bar = document_tab_bar(app, colors); let canvas = { let doc = &app.documents[app.active_doc]; @@ -238,6 +237,7 @@ pub fn panel_body<'a>( ts2.vector_radius, ts2.vector_points, ts2.vector_sides, + ts2.vector_angle, active_id, app.fg_color, app.bg_color, @@ -314,7 +314,7 @@ fn document_tab_bar<'a>( let tab = button( row![ tab_text, - iced::widget::tooltip( + styles::balloon_tooltip( button(text("×").size(10)) .on_press(Message::DocClose(idx)) .padding([0, 4]) @@ -340,8 +340,9 @@ fn document_tab_bar<'a>( } } ), - text(format!("Close {}", doc.name)).size(11), + format!("Close {}", doc.name), iced::widget::tooltip::Position::Bottom, + colors, ), ] .spacing(4) @@ -500,25 +501,29 @@ fn make_title_bar<'a>( .padding([3, 6]) .style(move |_theme, status| chrome_button_style(colors, status)) }; - let pin = iced::widget::tooltip( + let pin = styles::balloon_tooltip( control("−", Message::PaneAutoHide(pane)), - text("Minimize to side rail").size(11), + "Minimize to side rail", iced::widget::tooltip::Position::Bottom, + colors, ); - let float_control = iced::widget::tooltip( + let float_control = styles::balloon_tooltip( control("↗", Message::PaneFloat(pane)), - text("Float panel").size(11), + "Float panel", iced::widget::tooltip::Position::Bottom, + colors, ); - let maximize = iced::widget::tooltip( + let maximize = styles::balloon_tooltip( control("□", Message::PaneMaximize(pane)), - text("Maximize / restore").size(11), + "Maximize / restore", iced::widget::tooltip::Position::Bottom, + colors, ); - let close = iced::widget::tooltip( + let close = styles::balloon_tooltip( control("×", Message::PaneClose(pane)), - text("Close panel").size(11), + "Close panel", iced::widget::tooltip::Position::Bottom, + colors, ); let compact_control = |label, message| { button(text(label).size(11)) @@ -526,25 +531,29 @@ fn make_title_bar<'a>( .padding([3, 3]) .style(move |_theme, status| chrome_button_style(colors, status)) }; - let compact_pin = iced::widget::tooltip( + let compact_pin = styles::balloon_tooltip( compact_control("−", Message::PaneAutoHide(pane)), - text("Minimize to side rail").size(11), + "Minimize to side rail", iced::widget::tooltip::Position::Bottom, + colors, ); - let compact_float = iced::widget::tooltip( + let compact_float = styles::balloon_tooltip( compact_control("↗", Message::PaneFloat(pane)), - text("Float panel").size(11), + "Float panel", iced::widget::tooltip::Position::Bottom, + colors, ); - let compact_maximize = iced::widget::tooltip( + let compact_maximize = styles::balloon_tooltip( compact_control("□", Message::PaneMaximize(pane)), - text("Maximize / restore").size(11), + "Maximize / restore", iced::widget::tooltip::Position::Bottom, + colors, ); - let compact_close = iced::widget::tooltip( + let compact_close = styles::balloon_tooltip( compact_control("×", Message::PaneClose(pane)), - text("Close panel").size(11), + "Close panel", iced::widget::tooltip::Position::Bottom, + colors, ); let full_controls = row![pin, float_control, maximize, close] .spacing(1) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/welcome.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/welcome.rs index 17b0640..6a9c8aa 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/welcome.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/welcome.rs @@ -29,10 +29,7 @@ pub fn view(colors: ThemeColors) -> Element<'static, Message> { .size(13) .color(colors.text_secondary), row![ - action( - "New Image", - Message::DialogOpen(ActiveDialog::NewImage) - ), + action("New Image", Message::DialogOpen(ActiveDialog::NewImage)), action("Open Image", Message::OpenFileRfd), ] .spacing(10), diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs index 3987721..d52038a 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs @@ -350,10 +350,11 @@ pub fn view( }, }, ); - tabs = tabs.push(iced::widget::tooltip( + tabs = tabs.push(styles::balloon_tooltip( btn, - text(format!("{label} (category filtering is not available yet)")).size(10), + format!("{label} (category filtering is not available yet)"), iced::widget::tooltip::Position::Bottom, + colors, )); } 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 1f0f407..799868d 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 @@ -107,6 +107,7 @@ pub fn view<'a>( vector_radius: f32, vector_points: u32, vector_sides: u32, + vector_angle: f32, active_layer_id: u64, foreground_color: [u8; 4], background_color: [u8; 4], @@ -131,7 +132,7 @@ pub fn view<'a>( } Tool::Select => selection_properties(selection_rect, colors), Tool::VectorSelect => { - vector_select_properties(selected_vector_shape, vector_shapes, colors) + vector_select_properties(selected_vector_shape, vector_shapes, vector_angle, colors) } Tool::VectorRect | Tool::VectorCircle @@ -252,6 +253,7 @@ fn layer_info_section<'a>( fn vector_select_properties<'a>( selected: Option, shapes: &[VectorShape], + vector_angle: f32, _colors: ThemeColors, ) -> Element<'a, Message> { match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) { @@ -315,6 +317,16 @@ fn vector_select_properties<'a>( }) .step(0.01) .width(Length::Fill), + row![ + text("Angle").size(BODY), + text(format!("{:.0}°", vector_angle)).size(BODY) + ] + .spacing(4), + slider(-180.0..=180.0, vector_angle, move |v| { + Message::VectorAngleChanged(v) + }) + .step(1.0) + .width(Length::Fill), row![ text("Fill").size(BODY), checkbox("", filled) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs index 90c8ff3..1ee915f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs @@ -96,6 +96,9 @@ pub struct ToolSettings { /// Text angle in degrees. #[serde(default)] pub text_angle: f32, + /// Vector shape rotation angle in degrees (GUI display units). + #[serde(default)] + pub vector_angle: f32, /// Whether the Text tool commits to a new layer each time. #[serde(default = "default_apply_to_new_layer")] pub tool_apply_to_new_layer: bool, @@ -201,6 +204,7 @@ impl Default for ToolSettings { selection_anti_alias: true, text_font: "Arial".to_string(), text_angle: 0.0, + vector_angle: 0.0, tool_apply_to_new_layer: true, last_active_tool: "Brush".to_string(), } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs b/hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs index e30a216..aae1cab 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs @@ -82,6 +82,13 @@ pub fn sync_shape_from_tool(app: &mut HcieIcedApp) { .engine .set_vector_shape_hardness(layer_id, shape_idx, app.tool_state.brush_hardness); + // Rotation angle (radians in engine, degrees in GUI) + app.documents[app.active_doc].engine.set_vector_shape_angle( + layer_id, + shape_idx, + ts.vector_angle.to_radians(), + ); + // Primary color (stroke color) app.documents[app.active_doc] .engine @@ -137,6 +144,7 @@ pub fn sync_tool_from_shape(app: &mut HcieIcedApp) { ts.vector_opacity = shape.shape_opacity(); app.tool_state.brush_hardness = shape.hardness(); ts.vector_fill = shape.is_filled(); + ts.vector_angle = shape.angle().to_degrees(); // Shape-specific properties match shape { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs index 60560f6..a660a33 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs @@ -163,7 +163,12 @@ pub fn view<'a>( expanded: bool, ) -> Element<'a, Message> { let sidebar_width = if expanded { 72 } else { 36 }; - log::info!("[sidebar] expanded={} active_slot={} sub_tools_open={}", expanded, active_slot, sub_tools_open); + log::info!( + "[sidebar] expanded={} active_slot={} sub_tools_open={}", + expanded, + active_slot, + sub_tools_open + ); // Tool buttons — single column or 2-column grid depending on expanded state let mut tool_list = column![].spacing(1); @@ -261,24 +266,26 @@ pub fn view<'a>( }); let fg_color_val = *fg_color; - let fg_clickable = iced::widget::tooltip( + let fg_clickable = styles::balloon_tooltip( iced::widget::mouse_area(fg_swatch).on_press(Message::FgColorChanged(fg_color_val)), - text("Foreground color - click to apply").size(11), + "Foreground color - click to apply", iced::widget::tooltip::Position::Right, + colors, ); // Make the background swatch clickable so a click promotes the background // color to the foreground (egui: toolbox.rs:355-358). let bg_color_val = *bg_color; - let bg_clickable = iced::widget::tooltip( + let bg_clickable = styles::balloon_tooltip( iced::widget::mouse_area(bg_swatch).on_press(Message::FgColorChanged(bg_color_val)), - text("Background color - click to promote to foreground").size(11), + "Background color - click to promote to foreground", iced::widget::tooltip::Position::Right, + colors, ); // Small reset-to-defaults button (black/white) below the swatches // (egui: toolbox.rs:351-354 resets fg=black/bg=white on a bottom-left click). - let reset_btn = iced::widget::tooltip( + let reset_btn = styles::balloon_tooltip( button(text("D").size(10).color(colors.text_secondary)) .on_press(Message::ResetColors) .padding([1, 3]) @@ -300,8 +307,9 @@ pub fn view<'a>( }, }, ), - text("Reset foreground/background colors").size(11), + "Reset foreground/background colors", iced::widget::tooltip::Position::Right, + colors, ); // Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap) @@ -321,7 +329,7 @@ pub fn view<'a>( right: 0.0, })) .push( - container(iced::widget::tooltip( + container(styles::balloon_tooltip( button(text("⇄").size(10).color(colors.text_primary)) .on_press(Message::SwapColors) .padding(1) @@ -349,8 +357,9 @@ pub fn view<'a>( } }, ), - text("Swap foreground/background colors").size(11), + "Swap foreground/background colors", iced::widget::tooltip::Position::Right, + colors, )) .padding(iced::Padding { top: 0.0, @@ -586,31 +595,30 @@ fn tool_button<'a>( let tool_icon_path = tool_icon(tool); let resolved_path = resolve_icon_path(tool_icon_path); - let popup_icon: Element<'_, Message> = - if resolved_path.exists() { - let handle = svg::Handle::from_path(&resolved_path); - svg(handle) - .width(18) - .height(18) - .style(move |_theme, _status| svg::Style { - color: Some(if is_tool_active { - iced::Color::WHITE - } else { - c.text_primary - }), - }) - .into() - } else { - // Fallback: show first letter of tool name when icon is missing - text(tool_name.chars().next().unwrap_or('?').to_string()) - .size(14) - .color(if is_tool_active { + let popup_icon: Element<'_, Message> = if resolved_path.exists() { + let handle = svg::Handle::from_path(&resolved_path); + svg(handle) + .width(18) + .height(18) + .style(move |_theme, _status| svg::Style { + color: Some(if is_tool_active { iced::Color::WHITE } else { c.text_primary - }) - .into() - }; + }), + }) + .into() + } else { + // Fallback: show first letter of tool name when icon is missing + text(tool_name.chars().next().unwrap_or('?').to_string()) + .size(14) + .color(if is_tool_active { + iced::Color::WHITE + } else { + c.text_primary + }) + .into() + }; // Wrap the icon in a fixed-size, centered container so the popup // button always occupies 32×28 pixels even when the SVG is still @@ -620,9 +628,7 @@ fn tool_button<'a>( .height(20) .center_x(Length::Fill) .center_y(Length::Fill); - let item_content = container(icon_container) - .width(32) - .height(28); + let item_content = container(icon_container).width(32).height(28); let item = button( container(item_content) @@ -645,7 +651,11 @@ fn tool_button<'a>( })), text_color: c.text_primary, border: iced::Border::default() - .color(if is_tool_active { c.accent } else { c.border_high }) + .color(if is_tool_active { + c.accent + } else { + c.border_high + }) .width(if is_tool_active { 1 } else { 0 }), ..Default::default() }, @@ -657,7 +667,11 @@ fn tool_button<'a>( })), text_color: c.text_primary, border: iced::Border::default() - .color(if is_tool_active { c.accent } else { c.border_high }) + .color(if is_tool_active { + c.accent + } else { + c.border_high + }) .width(if is_tool_active { 1 } else { 0 }), ..Default::default() }, diff --git a/hcie-vector/src/lib.rs b/hcie-vector/src/lib.rs index c258501..e1581d6 100644 --- a/hcie-vector/src/lib.rs +++ b/hcie-vector/src/lib.rs @@ -564,27 +564,34 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) { } } Cross { x1, y1, x2, y2, stroke, color, fill_color, fill, opacity, angle, .. } => { + // Plus sign: four narrow arms extending from center. let (left, top, right, bottom) = normalized_rect(*x1, *y1, *x2, *y2); let cx = (left + right) / 2.0; let cy = (top + bottom) / 2.0; - let hw = (right - left) * 0.3; - let hh = (bottom - top) * 0.3; + let bw = right - left; + let bh = bottom - top; + // Arm width = 25% of the shorter dimension for a balanced plus. + let arm = bw.min(bh) * 0.25; let alpha = (color[3] as f32 * opacity).round() as u8; let px_color = [color[0], color[1], color[2], alpha]; let mut pts = vec![ - (cx - hw, top), - (cx + hw, top), - (cx + hw, cy - hh), - (right, cy - hh), - (right, cy + hh), - (cx + hw, cy + hh), - (cx + hw, bottom), - (cx - hw, bottom), - (cx - hw, cy + hh), - (left, cy + hh), - (left, cy - hh), - (cx - hw, cy - hh), + // Top arm + (cx - arm, top), + (cx + arm, top), + (cx + arm, cy - arm), + // Right arm + (right, cy - arm), + (right, cy + arm), + // Bottom arm + (cx + arm, cy + arm), + (cx + arm, bottom), + (cx - arm, bottom), + // Left arm + (cx - arm, cy + arm), + (left, cy + arm), + (left, cy - arm), + (cx - arm, cy - arm), ]; for p in &mut pts { @@ -633,14 +640,33 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) { } Bolt { x1, y1, x2, y2, stroke, color, fill_color, fill, opacity, angle, .. } => { + // Lightning bolt: classic top-right to bottom-left zigzag. + // Points flow: top tip → right shoulder → center notch → left notch → bottom tip let alpha = (color[3] as f32 * opacity).round() as u8; let px_color = [color[0], color[1], color[2], alpha]; let cx = (*x1 + *x2) / 2.0; let cy = (*y1 + *y2) / 2.0; - let mid_x = (*x1 + *x2) / 2.0; - + let left = *x1; + let right = *x2; + let top = *y1; + let bottom = *y2; + let w = right - left; + let h = bottom - top; + // Notch offset from center — creates the zigzag cut. + let nx = w * 0.18; let mut pts = vec![ - (*x1, *y1), (mid_x, *y2 * 0.7 + *y1 * 0.3), (*x2, *y1 * 0.3 + *y2 * 0.7), (*x2, *y2), + // Top tip (slightly right of center) + (cx + nx, top), + // Right shoulder (top-right corner area) + (right, top + h * 0.28), + // Zigzag inward to center-left + (cx - nx, cy - h * 0.05), + // Zigzag back to center-right + (cx + nx, cy + h * 0.05), + // Left shoulder (bottom-left corner area) + (left, bottom - h * 0.28), + // Bottom tip (slightly left of center) + (cx - nx, bottom), ]; for p in &mut pts { @@ -654,19 +680,51 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) { let px_fill = [fill_color[0], fill_color[1], fill_color[2], fill_alpha]; fill_polygon(layer, &pts, px_fill); } - draw_line(layer, pts[0].0, pts[0].1, pts[1].0, pts[1].1, px_color, *stroke, None); - draw_line(layer, pts[1].0, pts[1].1, pts[2].0, pts[2].1, px_color, *stroke, None); - draw_line(layer, pts[2].0, pts[2].1, pts[3].0, pts[3].1, px_color, *stroke, None); + for i in 0..pts.len() { + let j = (i + 1) % pts.len(); + draw_line(layer, pts[i].0, pts[i].1, pts[j].0, pts[j].1, px_color, *stroke, None); + } } Arrow4 { x1, y1, x2, y2, stroke, color, fill_color, fill, opacity, angle, .. } => { + // 4-way arrow: cross-shaped body with triangular arrowheads at each end. let (left, top, right, bottom) = normalized_rect(*x1, *y1, *x2, *y2); let cx = (left + right) / 2.0; let cy = (top + bottom) / 2.0; let alpha = (color[3] as f32 * opacity).round() as u8; let px_color = [color[0], color[1], color[2], alpha]; - + // Arm width is 25% of each half-dimension. + let arm_w = (right - left) * 0.125; + let arm_h = (bottom - top) * 0.125; + // Arrowhead extends 30% inward from the tip. + let head_extend_x = (right - cx) * 0.3; + let head_extend_y = (bottom - cy) * 0.3; + + // Full 12-point cross+arrowhead polygon: + // Up arm with arrowhead let mut pts = vec![ - (cx, top), (right, cy), (cx, bottom), (left, cy), + (cx - arm_w, cy - arm_h), // inner left of up arm + (cx - arm_w, top + head_extend_y), // left side near tip + (cx, top), // up tip + (cx + arm_w, top + head_extend_y), // right side near tip + (cx + arm_w, cy - arm_h), // inner right of up arm + // Right arm with arrowhead + (cx + arm_w, cy - arm_h), // shared + (right - head_extend_x, cy - arm_h), // top side near tip + (right, cy), // right tip + (right - head_extend_x, cy + arm_h), // bottom side near tip + (cx + arm_w, cy + arm_h), // inner bottom of right arm + // Down arm with arrowhead + (cx + arm_w, cy + arm_h), // shared + (cx + arm_w, bottom - head_extend_y), // right side near tip + (cx, bottom), // down tip + (cx - arm_w, bottom - head_extend_y), // left side near tip + (cx - arm_w, cy + arm_h), // inner left of down arm + // Left arm with arrowhead + (cx - arm_w, cy + arm_h), // shared + (left + head_extend_x, cy + arm_h), // bottom side near tip + (left, cy), // left tip + (left + head_extend_x, cy - arm_h), // top side near tip + (cx - arm_w, cy - arm_h), // inner top of left arm (closes) ]; for p in &mut pts { @@ -680,10 +738,10 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) { let px_fill = [fill_color[0], fill_color[1], fill_color[2], fill_alpha]; fill_polygon(layer, &pts, px_fill); } - draw_line(layer, pts[0].0, pts[0].1, pts[1].0, pts[1].1, px_color, *stroke, None); - draw_line(layer, pts[1].0, pts[1].1, pts[2].0, pts[2].1, px_color, *stroke, None); - draw_line(layer, pts[2].0, pts[2].1, pts[3].0, pts[3].1, px_color, *stroke, None); - draw_line(layer, pts[3].0, pts[3].1, pts[0].0, pts[0].1, px_color, *stroke, None); + for i in 0..pts.len() { + let j = (i + 1) % pts.len(); + draw_line(layer, pts[i].0, pts[i].1, pts[j].0, pts[j].1, px_color, *stroke, None); + } } FreePath { pts, stroke, color, fill, fill_color, opacity, .. } => { let alpha = (color[3] as f32 * opacity).round() as u8;