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.
This commit is contained in:
2026-07-18 23:02:28 +03:00
parent a2846d8b3a
commit bbabd2acdb
17 changed files with 1076 additions and 673 deletions
+315 -251
View File
@@ -14,6 +14,7 @@
mod transform_dirty; mod transform_dirty;
use self::transform_dirty::preview_bounds;
use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT}; use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT};
use crate::dialogs; use crate::dialogs;
use crate::dock::manager::DockDragState; use crate::dock::manager::DockDragState;
@@ -24,7 +25,6 @@ use crate::panels;
use crate::selection; use crate::selection;
use crate::selection::{CropState, SelectionTransform, TransformHandle}; use crate::selection::{CropState, SelectionTransform, TransformHandle};
use crate::theme::ThemeState; use crate::theme::ThemeState;
use self::transform_dirty::preview_bounds;
use hcie_engine_api::{ use hcie_engine_api::{
BlendMode, BrushStyle, BrushTip, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN, 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)>, pub vector_drag_last: Option<(f32, f32)>,
/// Timestamp of the last expensive vector rerasterization during a drag. /// Timestamp of the last expensive vector rerasterization during a drag.
pub vector_drag_last_render: Option<std::time::Instant>, pub vector_drag_last_render: Option<std::time::Instant>,
/// 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<f32>,
/// Immutable basis for the active vector drag. /// Immutable basis for the active vector drag.
pub vector_drag_session: Option<crate::vector_edit::VectorDragSession>, pub vector_drag_session: Option<crate::vector_edit::VectorDragSession>,
/// Boolean operation shape A selector index (geometry panel). /// Boolean operation shape A selector index (geometry panel).
@@ -434,6 +438,9 @@ pub struct IcedDocument {
pub vector_cycle_index: usize, pub vector_cycle_index: usize,
/// Which vector edit handle is active (hovered or being dragged). /// Which vector edit handle is active (hovered or being dragged).
pub vector_edit_handle: hcie_engine_api::VectorEditHandle, 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<hcie_engine_api::VectorShape>,
} }
/// In-progress text layer being authored with the Text tool. /// In-progress text layer being authored with the Text tool.
@@ -672,6 +679,7 @@ pub enum Message {
VectorRadiusChanged(f32), VectorRadiusChanged(f32),
VectorPointsChanged(u32), VectorPointsChanged(u32),
VectorSidesChanged(u32), VectorSidesChanged(u32),
VectorAngleChanged(f32),
TextFontChanged(String), TextFontChanged(String),
TextAngleChanged(f32), TextAngleChanged(f32),
TextOrientationChanged(String), TextOrientationChanged(String),
@@ -778,6 +786,7 @@ pub enum Message {
GeometryBoundsY1(f32), GeometryBoundsY1(f32),
GeometryBoundsX2(f32), GeometryBoundsX2(f32),
GeometryBoundsY2(f32), GeometryBoundsY2(f32),
GeometryAngleChanged(f32),
GeometryFillToggled(bool), GeometryFillToggled(bool),
GeometryFillColorChanged([u8; 4]), GeometryFillColorChanged([u8; 4]),
GeometryStrokeColorChanged([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); let upload_bounds = union_regions(pending, bounds);
doc.dirty_region.replace(Some(upload_bounds)); doc.dirty_region.replace(Some(upload_bounds));
if let Some(shader_pixels) = Arc::get_mut(&mut doc.composite_pixels) { 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 { } else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone()); doc.composite_pixels = Arc::new(doc.composite_raw.clone());
} }
@@ -1321,6 +1335,7 @@ impl HcieIcedApp {
selected_vector_shape: None, selected_vector_shape: None,
vector_cycle_index: 0, vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None, vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
vector_drag_preview: None,
}; };
// Load saved settings // Load saved settings
@@ -1336,7 +1351,8 @@ impl HcieIcedApp {
last_cursor_pos: None, last_cursor_pos: None,
canvas_cursor_pos: None, canvas_cursor_pos: None,
active_dialog: ActiveDialog::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, sub_tools_open: false,
sidebar_expanded: settings.panel_layout.sidebar_expanded, sidebar_expanded: settings.panel_layout.sidebar_expanded,
slot_last_used: vec![0; 20], slot_last_used: vec![0; 20],
@@ -1423,6 +1439,8 @@ impl HcieIcedApp {
vector_shapes_snapshot: None, vector_shapes_snapshot: None,
vector_drag_last: None, vector_drag_last: None,
vector_drag_last_render: None, vector_drag_last_render: None,
vector_drag_last_bounds: None,
vector_drag_last_angle: None,
vector_drag_session: None, vector_drag_session: None,
bool_shape_a: None, bool_shape_a: None,
bool_shape_b: None, bool_shape_b: None,
@@ -1444,7 +1462,8 @@ impl HcieIcedApp {
{ {
app.dock.reopen_pane(panel); app.dock.reopen_pane(panel);
if let Some(pane) = app.dock.pane_for_type(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 if let Some(panel) = app
@@ -1455,10 +1474,8 @@ impl HcieIcedApp {
{ {
app.dock.reopen_pane(panel); app.dock.reopen_pane(panel);
if let Some(pane) = app.dock.pane_for_type(panel) { if let Some(pane) = app.dock.pane_for_type(panel) {
app.dock.auto_hide_pane( app.dock
pane, .auto_hide_pane(pane, Some(crate::dock::manager::DockEdge::Right));
Some(crate::dock::manager::DockEdge::Right),
);
} }
} }
if app if app
@@ -1755,6 +1772,11 @@ impl HcieIcedApp {
self.vector_drag_session = None; self.vector_drag_session = None;
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_last_render = 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; return;
} }
self.documents.remove(document_index); self.documents.remove(document_index);
@@ -1766,6 +1788,9 @@ impl HcieIcedApp {
self.vector_shapes_snapshot = None; self.vector_shapes_snapshot = None;
self.vector_drag_session = None; self.vector_drag_session = None;
self.vector_drag_last = 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). /// Add a color to the recent colors list (most recent first, max 40).
@@ -1829,13 +1854,13 @@ impl HcieIcedApp {
std::ptr::copy_nonoverlapping( std::ptr::copy_nonoverlapping(
src_ptr.add(offset), src_ptr.add(offset),
dst_raw_ptr.add(offset), dst_raw_ptr.add(offset),
row_bytes row_bytes,
); );
if !pixels_ptr.is_null() { if !pixels_ptr.is_null() {
std::ptr::copy_nonoverlapping( std::ptr::copy_nonoverlapping(
src_ptr.add(offset), src_ptr.add(offset),
pixels_ptr.add(offset), pixels_ptr.add(offset),
row_bytes row_bytes,
); );
} }
} }
@@ -2487,106 +2512,112 @@ impl HcieIcedApp {
if is_selection_tool { if is_selection_tool {
// Fall through to the dedicated selection drag handlers below. // Fall through to the dedicated selection drag handlers below.
} else { } else {
let is_ctrl = self.modifiers.control() || self.modifiers.logo(); let is_ctrl = self.modifiers.control() || self.modifiers.logo();
if is_ctrl { if is_ctrl {
// Pick color // Pick color
let cx = x as u32; let cx = x as u32;
let cy = y as u32; let cy = y as u32;
let w = self.documents[self.active_doc].engine.canvas_width(); let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height(); let h = self.documents[self.active_doc].engine.canvas_height();
if cx < w && cy < h { if cx < w && cy < h {
let pixels = &self.documents[self.active_doc].composite_raw; let pixels = &self.documents[self.active_doc].composite_raw;
let idx = ((cy * w + cx) * 4) as usize; let idx = ((cy * w + cx) * 4) as usize;
if idx + 3 < pixels.len() { if idx + 3 < pixels.len() {
let color = [ let color = [
pixels[idx], pixels[idx],
pixels[idx + 1], pixels[idx + 1],
pixels[idx + 2], pixels[idx + 2],
pixels[idx + 3], pixels[idx + 3],
]; ];
self.fg_color = color; self.fg_color = color;
self.documents[self.active_doc].engine.set_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 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(); } 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 spacing: f32 = match self.tool_state.active_tool { let new_size = (self.tool_state.brush_resize_start_size + dx)
Tool::Spray => 2.0_f32, .clamp(1.0, 500.0);
_ => 1.0_f32, self.tool_state.brush_size = new_size;
} match self.tool_state.active_tool {
.max(1.0); Tool::Pen => self.settings.tool_settings.pen_size = new_size,
Tool::Brush => {
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { self.settings.tool_settings.brush_size = new_size
let dx = x - last_x; }
let dy = y - last_y; Tool::Eraser => {
let dist = (dx * dx + dy * dy).sqrt(); self.settings.tool_settings.eraser_size = new_size
}
// Use constant pressure for mouse input Tool::Spray => {
// Real tablet pressure comes from tablet_state self.settings.tool_settings.spray_radius = new_size
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 { } else {
self.documents[self.active_doc] let layer_id = self.documents[self.active_doc].engine.active_layer_id();
.engine
.stroke_to(layer_id, x, y, 1.0); if self.tool_state.active_tool == Tool::Pen {
self.tool_state.last_stroke_pos = Some((x, y)); 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) } // end else (non-selection tool)
} }
@@ -2716,17 +2747,17 @@ impl HcieIcedApp {
| Tool::SmartSelect | Tool::SmartSelect
); );
if !is_selection_tool { if !is_selection_tool {
let layer_id = self.documents[self.active_doc].engine.active_layer_id(); 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.end_stroke(layer_id);
self.documents[self.active_doc] self.documents[self.active_doc]
.engine .engine
.commit_pending_history(); .commit_pending_history();
self.tool_state.is_drawing = false; self.tool_state.is_drawing = false;
self.tool_state.last_stroke_pos = None; self.tool_state.last_stroke_pos = None;
self.documents[self.active_doc].modified = true; self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
} // end if !is_selection_tool } // end if !is_selection_tool
} }
@@ -2815,7 +2846,9 @@ impl HcieIcedApp {
Message::Undo => { Message::Undo => {
let doc = &mut self.documents[self.active_doc]; let doc = &mut self.documents[self.active_doc];
let current_marker = doc.engine.history_current(); 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 // Undo selection
if let Some(prev_mask) = doc.selection_history.pop() { if let Some(prev_mask) = doc.selection_history.pop() {
if let Some(mask) = prev_mask { if let Some(mask) = prev_mask {
@@ -2856,6 +2889,7 @@ impl HcieIcedApp {
self.vector_shapes_snapshot = None; self.vector_shapes_snapshot = None;
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_session = None; self.vector_drag_session = None;
doc.vector_drag_preview = None;
self.bool_shape_a = None; self.bool_shape_a = None;
self.bool_shape_b = None; self.bool_shape_b = None;
doc.engine.set_active_layer(id); doc.engine.set_active_layer(id);
@@ -3061,103 +3095,101 @@ impl HcieIcedApp {
// - Style doesn't exist → create default style with enabled: true // - Style doesn't exist → create default style with enabled: true
// - Style exists → clone and toggle enabled flag // - Style exists → clone and toggle enabled flag
let new_style = match existing { let new_style = match existing {
None => { None => match disc {
match disc { "DropShadow" => LayerStyle::DropShadow {
"DropShadow" => LayerStyle::DropShadow { enabled: true,
enabled: true, opacity: 0.75,
opacity: 0.75, angle: 120.0,
angle: 120.0, distance: 5.0,
distance: 5.0, spread: 0.0,
spread: 0.0, size: 5.0,
size: 5.0, color: [0, 0, 0, 255],
color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(),
blend_mode: "Multiply".to_string(), },
}, "InnerShadow" => LayerStyle::InnerShadow {
"InnerShadow" => LayerStyle::InnerShadow { enabled: true,
enabled: true, opacity: 0.75,
opacity: 0.75, angle: 120.0,
angle: 120.0, distance: 5.0,
distance: 5.0, spread: 0.0,
spread: 0.0, size: 5.0,
size: 5.0, color: [0, 0, 0, 255],
color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(),
blend_mode: "Multiply".to_string(), },
}, "OuterGlow" => LayerStyle::OuterGlow {
"OuterGlow" => LayerStyle::OuterGlow { enabled: true,
enabled: true, opacity: 0.75,
opacity: 0.75, spread: 0.0,
spread: 0.0, size: 5.0,
size: 5.0, color: [255, 255, 190, 255],
color: [255, 255, 190, 255], blend_mode: "Screen".to_string(),
blend_mode: "Screen".to_string(), },
}, "InnerGlow" => LayerStyle::InnerGlow {
"InnerGlow" => LayerStyle::InnerGlow { enabled: true,
enabled: true, opacity: 0.75,
opacity: 0.75, spread: 0.0,
spread: 0.0, size: 5.0,
size: 5.0, color: [255, 255, 190, 255],
color: [255, 255, 190, 255], blend_mode: "Screen".to_string(),
blend_mode: "Screen".to_string(), },
}, "BevelEmboss" => LayerStyle::BevelEmboss {
"BevelEmboss" => LayerStyle::BevelEmboss { enabled: true,
enabled: true, depth: 1.0,
depth: 1.0, size: 5.0,
size: 5.0, angle: 120.0,
angle: 120.0, altitude: 30.0,
altitude: 30.0, highlight_opacity: 0.75,
highlight_opacity: 0.75, shadow_opacity: 0.75,
shadow_opacity: 0.75, direction: "Up".to_string(),
direction: "Up".to_string(), style: "InnerBevel".to_string(),
style: "InnerBevel".to_string(), technique: "Smooth".to_string(),
technique: "Smooth".to_string(), soften: 0.0,
soften: 0.0, highlight_blend_mode: "Screen".to_string(),
highlight_blend_mode: "Screen".to_string(), highlight_color: [255, 255, 255, 255],
highlight_color: [255, 255, 255, 255], shadow_blend_mode: "Multiply".to_string(),
shadow_blend_mode: "Multiply".to_string(), shadow_color: [0, 0, 0, 255],
shadow_color: [0, 0, 0, 255], contour: "Linear".to_string(),
contour: "Linear".to_string(), },
}, "Satin" => LayerStyle::Satin {
"Satin" => LayerStyle::Satin { enabled: true,
enabled: true, opacity: 0.5,
opacity: 0.5, angle: 120.0,
angle: 120.0, distance: 11.0,
distance: 11.0, size: 14.0,
size: 14.0, color: [0, 0, 0, 255],
color: [0, 0, 0, 255], invert: true,
invert: true, },
}, "ColorOverlay" => LayerStyle::ColorOverlay {
"ColorOverlay" => LayerStyle::ColorOverlay { enabled: true,
enabled: true, opacity: 1.0,
opacity: 1.0, color: [255, 0, 0, 255],
color: [255, 0, 0, 255], blend_mode: "Normal".to_string(),
blend_mode: "Normal".to_string(), },
}, "GradientOverlay" => LayerStyle::GradientOverlay {
"GradientOverlay" => LayerStyle::GradientOverlay { enabled: true,
enabled: true, opacity: 1.0,
opacity: 1.0, blend_mode: "Normal".to_string(),
blend_mode: "Normal".to_string(), angle: 90.0,
angle: 90.0, scale: 1.0,
scale: 1.0, gradient_type: 0,
gradient_type: 0, },
}, "PatternOverlay" => LayerStyle::PatternOverlay {
"PatternOverlay" => LayerStyle::PatternOverlay { enabled: true,
enabled: true, opacity: 1.0,
opacity: 1.0, blend_mode: "Normal".to_string(),
blend_mode: "Normal".to_string(), scale: 1.0,
scale: 1.0, pattern_name: "".to_string(),
pattern_name: "".to_string(), },
}, "Stroke" => LayerStyle::Stroke {
"Stroke" => LayerStyle::Stroke { enabled: true,
enabled: true, size: 3.0,
size: 3.0, position: "Outside".to_string(),
position: "Outside".to_string(), opacity: 1.0,
opacity: 1.0, color: [255, 0, 0, 255],
color: [255, 0, 0, 255], blend_mode: "Normal".to_string(),
blend_mode: "Normal".to_string(), },
}, _ => unreachable!(),
_ => unreachable!(), },
}
}
Some(s) => { Some(s) => {
let mut cloned = s.clone(); let mut cloned = s.clone();
match &mut cloned { match &mut cloned {
@@ -4095,32 +4127,51 @@ impl HcieIcedApp {
Message::VectorStrokeChanged(s) => { Message::VectorStrokeChanged(s) => {
self.settings.tool_settings.vector_stroke_width = s; self.settings.tool_settings.vector_stroke_width = s;
crate::shape_sync::sync_shape_from_tool(self); crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save(); let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
Message::VectorOpacityChanged(o) => { Message::VectorOpacityChanged(o) => {
self.settings.tool_settings.vector_opacity = o; self.settings.tool_settings.vector_opacity = o;
crate::shape_sync::sync_shape_from_tool(self); crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save(); let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
Message::VectorFillToggled(f) => { Message::VectorFillToggled(f) => {
self.settings.tool_settings.vector_fill = f; self.settings.tool_settings.vector_fill = f;
crate::shape_sync::sync_shape_from_tool(self); crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save(); let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
Message::VectorRadiusChanged(r) => { Message::VectorRadiusChanged(r) => {
self.settings.tool_settings.vector_radius = r; self.settings.tool_settings.vector_radius = r;
crate::shape_sync::sync_shape_from_tool(self); crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save(); let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
Message::VectorPointsChanged(p) => { Message::VectorPointsChanged(p) => {
self.settings.tool_settings.vector_points = p; self.settings.tool_settings.vector_points = p;
crate::shape_sync::sync_shape_from_tool(self); crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save(); let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
Message::VectorSidesChanged(s) => { Message::VectorSidesChanged(s) => {
self.settings.tool_settings.vector_sides = s; self.settings.tool_settings.vector_sides = s;
crate::shape_sync::sync_shape_from_tool(self); crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save(); 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) => { Message::TextFontChanged(font) => {
self.settings.tool_settings.text_font = font.clone(); self.settings.tool_settings.text_font = font.clone();
@@ -4494,6 +4545,7 @@ impl HcieIcedApp {
selected_vector_shape: None, selected_vector_shape: None,
vector_cycle_index: 0, vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None, vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
vector_drag_preview: None,
}; };
if self.show_welcome && self.documents.len() == 1 { if self.show_welcome && self.documents.len() == 1 {
// Replace the placeholder empty document created for the // Replace the placeholder empty document created for the
@@ -4709,13 +4761,18 @@ impl HcieIcedApp {
self.documents[self.active_doc].selection_rect = None; self.documents[self.active_doc].selection_rect = None;
} }
Message::SelectAll => { 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(); let current_marker = self.documents[self.active_doc].engine.history_current();
if current_marker != self.documents[self.active_doc].selection_history_marker { 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.clear();
self.documents[self.active_doc].selection_history_marker = current_marker; 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 w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height(); let h = self.documents[self.active_doc].engine.canvas_height();
@@ -5270,6 +5327,7 @@ impl HcieIcedApp {
doc.selected_vector_shape = None; doc.selected_vector_shape = None;
doc.vector_cycle_index = 0; doc.vector_cycle_index = 0;
doc.vector_edit_handle = VEH::None; doc.vector_edit_handle = VEH::None;
doc.vector_drag_preview = None;
self.vector_shapes_snapshot = None; self.vector_shapes_snapshot = None;
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_session = None; self.vector_drag_session = None;
@@ -5280,6 +5338,7 @@ impl HcieIcedApp {
doc.selected_vector_shape = None; doc.selected_vector_shape = None;
doc.vector_cycle_index = 0; doc.vector_cycle_index = 0;
doc.vector_edit_handle = VEH::None; doc.vector_edit_handle = VEH::None;
doc.vector_drag_preview = None;
self.vector_shapes_snapshot = None; self.vector_shapes_snapshot = None;
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_session = None; self.vector_drag_session = None;
@@ -5321,6 +5380,7 @@ impl HcieIcedApp {
{ {
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_session = None; self.vector_drag_session = None;
self.documents[self.active_doc].vector_drag_preview = None;
return Task::none(); return Task::none();
} }
let shape = crate::vector_edit::transform_shape( let shape = crate::vector_edit::transform_shape(
@@ -5331,42 +5391,26 @@ impl HcieIcedApp {
); );
let (x1, y1, x2, y2) = shape.normalized_bounds(); let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle(); let angle = shape.angle();
if let Some(shapes) = self.documents[self.active_doc]
.engine // Keep the transformed shape in a lightweight overlay preview
.get_layer_shapes_direct(session.layer_id) // instead of mutating the engine on every pointer event. The
{ // expensive vector rasterization and full-layer composite only
if let Some(current) = shapes.get_mut(session.shape_index) { // happen once when the drag ends.
*current = shape; self.documents[self.active_doc].vector_drag_preview = Some(shape);
}
}
self.documents[self.active_doc].modified = true; self.documents[self.active_doc].modified = true;
self.vector_drag_last = Some((x, y)); self.vector_drag_last = Some((x, y));
let should_render = self self.vector_drag_last_bounds = Some((x1, y1, x2, y2));
.vector_drag_last_render self.vector_drag_last_angle = Some(angle);
.is_none_or(|last| last.elapsed() >= std::time::Duration::from_millis(16)); self.settings.tool_settings.vector_angle = angle.to_degrees();
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();
}
} }
} }
Message::VectorSelectDragEnd => { Message::VectorSelectDragEnd => {
if self.vector_drag_last.is_some() { 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(session) = self.vector_drag_session.as_ref() {
if let Some(shape) = self.documents[self.active_doc] if let Some(shape) =
.engine self.documents[self.active_doc].vector_drag_preview.take()
.active_vector_shapes()
.and_then(|shapes| shapes.get(session.shape_index).cloned())
{ {
let (x1, y1, x2, y2) = shape.normalized_bounds(); let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle(); let angle = shape.angle();
@@ -5389,7 +5433,10 @@ impl HcieIcedApp {
// Commit the move/resize/rotate: if shapes changed, push history snapshot. // Commit the move/resize/rotate: if shapes changed, push history snapshot.
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_last_render = 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.vector_drag_session = None;
self.documents[self.active_doc].vector_drag_preview = None;
self.documents[self.active_doc].vector_edit_handle = self.documents[self.active_doc].vector_edit_handle =
hcie_engine_api::VectorEditHandle::None; hcie_engine_api::VectorEditHandle::None;
self.refresh_composite_if_needed(); 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].selected_vector_shape = None;
self.documents[self.active_doc].modified = true; self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].vector_cycle_index = 0; 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_shapes_snapshot = None;
self.vector_drag_session = None; self.vector_drag_session = None;
self.documents[self.active_doc] self.documents[self.active_doc]
@@ -5433,6 +5481,7 @@ impl HcieIcedApp {
self.vector_shapes_snapshot = None; self.vector_shapes_snapshot = None;
} }
self.vector_drag_session = None; self.vector_drag_session = None;
self.documents[self.active_doc].vector_drag_preview = None;
} }
// ── Vector shape apply / cancel / flip ────── // ── Vector shape apply / cancel / flip ──────
Message::VectorShapeApply => { Message::VectorShapeApply => {
@@ -5441,6 +5490,7 @@ impl HcieIcedApp {
doc.selected_vector_shape = None; doc.selected_vector_shape = None;
doc.vector_cycle_index = 0; doc.vector_cycle_index = 0;
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
doc.vector_drag_preview = None;
self.vector_shapes_snapshot = None; self.vector_shapes_snapshot = None;
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_session = None; self.vector_drag_session = None;
@@ -5455,6 +5505,7 @@ impl HcieIcedApp {
doc.selected_vector_shape = None; doc.selected_vector_shape = None;
doc.vector_cycle_index = 0; doc.vector_cycle_index = 0;
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
doc.vector_drag_preview = None;
self.vector_drag_last = None; self.vector_drag_last = None;
self.vector_drag_session = None; self.vector_drag_session = None;
doc.engine.mark_composite_dirty(); 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) => { Message::GeometryFillToggled(v) => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let layer_id = self.documents[self.active_doc].engine.active_layer_id();
@@ -6524,13 +6589,11 @@ impl HcieIcedApp {
.last_cursor_pos .last_cursor_pos
.map(|(x, y)| iced::Point::new(x, y)) .map(|(x, y)| iced::Point::new(x, y))
.unwrap_or_default(); .unwrap_or_default();
self.dock.header_drag = Some( self.dock.header_drag = Some(crate::dock::manager::DockHeaderDragState {
crate::dock::manager::DockHeaderDragState { source_pane: pane,
source_pane: pane, source_type,
source_type, origin,
origin, });
},
);
self.dock.focus(pane); self.dock.focus(pane);
} }
} }
@@ -6870,8 +6933,8 @@ impl HcieIcedApp {
selection_mask: None, selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false), selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None, selection_bounds: None,
selection_history: Vec::new(), selection_history: Vec::new(),
selection_history_marker: 0, selection_history_marker: 0,
crop_state: CropState::default(), crop_state: CropState::default(),
lasso_points: Vec::new(), lasso_points: Vec::new(),
polygon_points: Vec::new(), polygon_points: Vec::new(),
@@ -6887,6 +6950,7 @@ impl HcieIcedApp {
selected_vector_shape: None, selected_vector_shape: None,
vector_cycle_index: 0, vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None, vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
vector_drag_preview: None,
}); });
} }
@@ -95,6 +95,8 @@ struct OverlayProgram {
selected_vector_angle: f32, selected_vector_angle: f32,
/// Which vector edit handle is active (hovered or being dragged). /// Which vector edit handle is active (hovered or being dragged).
vector_edit_handle: hcie_engine_api::VectorEditHandle, vector_edit_handle: hcie_engine_api::VectorEditHandle,
/// Transformed vector shape being previewed during a drag (not yet committed).
vector_drag_preview: Option<hcie_engine_api::VectorShape>,
} }
/// Overlay state — tracks hover and cursor position for crosshair. /// 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<Point> = (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<Message> for OverlayProgram { impl canvas::Program<Message> for OverlayProgram {
@@ -653,7 +791,7 @@ impl canvas::Program<Message> for OverlayProgram {
// shape outline, not just a bounding box). // shape outline, not just a bounding box).
if let Some(((x0, y0), (x1, y1))) = self.vector_draw { if let Some(((x0, y0), (x1, y1))) = self.vector_draw {
let stroke_style = Stroke { 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), width: 2.0 / self.zoom.max(1.0),
line_dash: canvas::LineDash { line_dash: canvas::LineDash {
segments: &[5.0, 5.0], segments: &[5.0, 5.0],
@@ -662,16 +800,24 @@ impl canvas::Program<Message> for OverlayProgram {
..Default::default() ..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 { match self.active_tool {
hcie_engine_api::Tool::VectorCircle => { hcie_engine_api::Tool::VectorCircle => {
// Ellipse preview using the bounding box center + radii. // Ellipse preview using the bounding box center + radii.
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; // Engine: cx=(left+right)/2, cy=(top+bottom)/2, rx=(right-left)/2, ry=(bottom-top)/2
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let rx = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; let cy = origin_y + ((top + bottom) / 2.0) * self.zoom;
let ry = ((y1 - y0).abs() / 2.0).max(1.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| { let ellipse_path = Path::new(|b| {
// Approximate ellipse with 48 segments.
let segments = 48;
for i in 0..=segments { for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU; let t = i as f32 / segments as f32 * std::f32::consts::TAU;
let px = cx + rx * t.cos(); let px = cx + rx * t.cos();
@@ -694,17 +840,18 @@ impl canvas::Program<Message> for OverlayProgram {
frame.stroke(&line_path, stroke_style); frame.stroke(&line_path, stroke_style);
} }
hcie_engine_api::Tool::VectorStar => { hcie_engine_api::Tool::VectorStar => {
// Star preview using user's configured point count. // Star preview — engine: outer_r = min(bw, bh)/2, inner_r = inner_radius * outer_r
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; // inner_radius defaults to 0.5 (VectorConfig), configurable per shape.
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let r_out = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; let cy = origin_y + ((top + bottom) / 2.0) * self.zoom;
let r_in = r_out * 0.5; 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 points = self.star_points.max(3);
let star_path = Path::new(|b| { let star_path = Path::new(|b| {
for i in 0..(points * 2) { for i in 0..(points * 2) {
let t = (i as f32) * std::f32::consts::PI / points as f32 let t = std::f32::consts::PI * 2.0 * i as f32 / (points * 2) as f32
- std::f32::consts::FRAC_PI_2; - std::f32::consts::PI / 2.0;
let r = if i % 2 == 0 { r_out } else { r_in }; let r = if i % 2 == 0 { outer_r } else { inner_r };
let px = cx + r * t.cos(); let px = cx + r * t.cos();
let py = cy + r * t.sin(); let py = cy + r * t.sin();
if i == 0 { if i == 0 {
@@ -718,15 +865,15 @@ impl canvas::Program<Message> for OverlayProgram {
frame.stroke(&star_path, stroke_style); frame.stroke(&star_path, stroke_style);
} }
hcie_engine_api::Tool::VectorPolygon => { hcie_engine_api::Tool::VectorPolygon => {
// Polygon preview using user's configured side count. // Polygon preview — engine: r = min(bw, bh)/2
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; let cy = origin_y + ((top + bottom) / 2.0) * self.zoom;
let r = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; let r = (bw.min(bh) / 2.0).max(1.0) * self.zoom;
let sides = self.polygon_sides.max(3); let sides = self.polygon_sides.max(3);
let poly_path = Path::new(|b| { let poly_path = Path::new(|b| {
for i in 0..sides { for i in 0..sides {
let t = (i as f32) * std::f32::consts::TAU / sides as f32 let t = std::f32::consts::PI * 2.0 * i as f32 / sides as f32
- std::f32::consts::FRAC_PI_2; - std::f32::consts::PI / 2.0;
let px = cx + r * t.cos(); let px = cx + r * t.cos();
let py = cy + r * t.sin(); let py = cy + r * t.sin();
if i == 0 { if i == 0 {
@@ -740,65 +887,64 @@ impl canvas::Program<Message> for OverlayProgram {
frame.stroke(&poly_path, stroke_style); frame.stroke(&poly_path, stroke_style);
} }
hcie_engine_api::Tool::VectorArrow => { hcie_engine_api::Tool::VectorArrow => {
// Arrow: triangle head at the end, shaft line from start to end. // Arrow — engine uses stroke-proportional thickness and head length.
let p0 = Point::new(origin_x + x0 * self.zoom, origin_y + y0 * self.zoom); // arrow_thickness = stroke * 1.8, head_len = arrow_thickness * 3
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.
let dx = x1 - x0; let dx = x1 - x0;
let dy = y1 - y0; let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt().max(1.0); let len = (dx * dx + dy * dy).sqrt().max(1.0);
let nx = dx / len; let head_angle = dy.atan2(dx);
let ny = dy / len; let arrow_angle = std::f32::consts::PI / 6.0;
let head_len = len * 0.25; let arrow_thickness = self.zoom; // stroke width in canvas space
let head_w = len * 0.18; let head_len = arrow_thickness * 3.0;
let hx = origin_x + x1 * self.zoom;
let hy = origin_y + y1 * self.zoom; let rx2 = origin_x + x1 * self.zoom;
let base_x = origin_x + (x1 - nx * head_len) * self.zoom; let ry2 = origin_y + y1 * self.zoom;
let base_y = origin_y + (y1 - ny * head_len) * self.zoom; let rx3 = rx2 - head_len * (head_angle + arrow_angle).cos();
let tip = Point::new(hx, hy); let ry3 = ry2 - head_len * (head_angle + arrow_angle).sin();
let left = Point::new(base_x - ny * head_w, base_y + nx * head_w); let rx4 = rx2 - head_len * (head_angle - arrow_angle).cos();
let right = Point::new(base_x + ny * head_w, base_y - nx * head_w); let ry4 = ry2 - head_len * (head_angle - arrow_angle).sin();
let head_path = Path::new(|b| {
b.move_to(tip); let p0 = Point::new(origin_x + x0 * self.zoom, origin_y + y0 * self.zoom);
b.line_to(left); let p1 = Point::new(rx2, ry2);
b.line_to(right); let p3 = Point::new(rx3, ry3);
b.close(); let p4 = Point::new(rx4, ry4);
}); // Shaft line
frame.stroke(&head_path, stroke_style); 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 => { hcie_engine_api::Tool::VectorRhombus => {
// Diamond shape: top, right, bottom, left of bounding box. // Diamond shape — matches engine: (cx,top), (right,cy), (cx,bottom), (left,cy)
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; let cy = origin_y + ((top + bottom) / 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 rhombus_path = Path::new(|b| { let rhombus_path = Path::new(|b| {
b.move_to(Point::new(cx, cy - hh)); // top b.move_to(Point::new(cx, origin_y + top * self.zoom));
b.line_to(Point::new(cx + hw, cy)); // right b.line_to(Point::new(origin_x + right * self.zoom, cy));
b.line_to(Point::new(cx, cy + hh)); // bottom b.line_to(Point::new(cx, origin_y + bottom * self.zoom));
b.line_to(Point::new(cx - hw, cy)); // left b.line_to(Point::new(origin_x + left * self.zoom, cy));
b.close(); b.close();
}); });
frame.stroke(&rhombus_path, stroke_style); frame.stroke(&rhombus_path, stroke_style);
} }
hcie_engine_api::Tool::VectorCylinder => { hcie_engine_api::Tool::VectorCylinder => {
// Cylinder: ellipse at top and bottom, vertical lines connecting them. // Cylinder — engine: ry = (bottom - top) * 0.1
let left = origin_x + x0.min(x1) * self.zoom; let left_scr = origin_x + left * self.zoom;
let right = origin_x + x0.max(x1) * self.zoom; let right_scr = origin_x + right * self.zoom;
let top = origin_y + y0.min(y1) * self.zoom; let top_scr = origin_y + top * self.zoom;
let bottom = origin_y + y0.max(y1) * self.zoom; let bottom_scr = origin_y + bottom * self.zoom;
let hw = (right - left) / 2.0; let hw = (right_scr - left_scr) / 2.0;
let ellipse_rx = hw; let ellipse_rx = hw;
let ellipse_ry = hw * 0.3; let ellipse_ry = (bottom - top) * 0.1 * self.zoom;
let segments = 36; let segments = 36;
// Top ellipse let top_cy = top_scr + ellipse_ry;
let top_cy = top + ellipse_ry;
let top_ellipse = Path::new(|b| { let top_ellipse = Path::new(|b| {
for i in 0..=segments { for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU; 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(); let py = top_cy + ellipse_ry * t.sin();
if i == 0 { if i == 0 {
b.move_to(Point::new(px, py)); b.move_to(Point::new(px, py));
@@ -809,12 +955,11 @@ impl canvas::Program<Message> for OverlayProgram {
b.close(); b.close();
}); });
frame.stroke(&top_ellipse, stroke_style); frame.stroke(&top_ellipse, stroke_style);
// Bottom ellipse let bot_cy = bottom_scr - ellipse_ry;
let bot_cy = bottom - ellipse_ry;
let bot_ellipse = Path::new(|b| { let bot_ellipse = Path::new(|b| {
for i in 0..=segments { for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU; 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(); let py = bot_cy + ellipse_ry * t.sin();
if i == 0 { if i == 0 {
b.move_to(Point::new(px, py)); b.move_to(Point::new(px, py));
@@ -825,48 +970,54 @@ impl canvas::Program<Message> for OverlayProgram {
b.close(); b.close();
}); });
frame.stroke(&bot_ellipse, stroke_style); frame.stroke(&bot_ellipse, stroke_style);
// Left vertical line
let left_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); frame.stroke(&left_line, stroke_style);
// Right vertical line let right_line = Path::line(
let right_line = Point::new(right_scr, top_cy),
Path::line(Point::new(right, top_cy), Point::new(right, bot_cy)); Point::new(right_scr, bot_cy),
);
frame.stroke(&right_line, stroke_style); frame.stroke(&right_line, stroke_style);
} }
hcie_engine_api::Tool::VectorHeart => { hcie_engine_api::Tool::VectorHeart => {
// Heart: two arcs at top meeting at bottom point. // Heart — engine uses parametric formula:
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; // x = cx + hw * (16*sin³t)/16
let top = origin_y + y0.min(y1) * self.zoom; // y = cy - hh * (13cos(t) - 5cos(2t) - 2cos(3t) - cos(4t))/16
let bottom = origin_y + y0.max(y1) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let hw = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; let cy = origin_y + ((top + bottom) / 2.0) * self.zoom;
let hh = bottom - top; let hw = (bw / 2.0).max(1.0) * self.zoom;
let cp_off = hw * 0.55; let hh = (bh / 2.0).max(1.0) * self.zoom;
let steps = 48;
let heart_path = Path::new(|b| { let heart_path = Path::new(|b| {
b.move_to(Point::new(cx, top + hh * 0.35)); for i in 0..=steps {
// Right curve let t = std::f32::consts::TAU * i as f32 / steps as f32;
b.bezier_curve_to( let px = cx + hw * (16.0 * t.sin().powi(3)) / 16.0;
Point::new(cx + cp_off, top - hh * 0.15), let py = cy
Point::new(cx + hw, top + hh * 0.1), - hh * (13.0 * t.cos()
Point::new(cx, bottom), - 5.0 * (2.0 * t).cos()
); - 2.0 * (3.0 * t).cos()
// Left curve - (4.0 * t).cos())
b.bezier_curve_to( / 16.0;
Point::new(cx - hw, top + hh * 0.1), if i == 0 {
Point::new(cx - cp_off, top - hh * 0.15), b.move_to(Point::new(px, py));
Point::new(cx, top + hh * 0.35), } else {
); b.line_to(Point::new(px, py));
}
}
b.close(); b.close();
}); });
frame.stroke(&heart_path, stroke_style); frame.stroke(&heart_path, stroke_style);
} }
hcie_engine_api::Tool::VectorBubble => { hcie_engine_api::Tool::VectorBubble => {
// Bubble: ellipse with a small triangle tail at bottom-left. // Bubble — engine: ellipse body + triangular tail at bottom-left.
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; // Tail: inner at (cx+rx*0.2, cy+ry*0.8), tip at (cx-rx*0.6, cy+ry*1.4),
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; // outer at (cx-rx*0.6, cy+ry*0.8)
let rx = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let ry = ((y1 - y0).abs() / 2.0).max(1.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 segments = 48;
// Full ellipse body
let bubble_path = Path::new(|b| { let bubble_path = Path::new(|b| {
for i in 0..=segments { for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU; let t = i as f32 / segments as f32 * std::f32::consts::TAU;
@@ -881,89 +1032,51 @@ impl canvas::Program<Message> for OverlayProgram {
b.close(); b.close();
}); });
frame.stroke(&bubble_path, stroke_style); frame.stroke(&bubble_path, stroke_style);
// Triangle tail at bottom-left. // Tail triangle
let tail_base_x = cx - rx * 0.35; let tail_inner = Point::new(cx + rx * 0.2, cy + ry * 0.8);
let tail_base_y = cy + ry * 0.7; let tail_tip = Point::new(cx - rx * 0.6, cy + ry * 1.4);
let tail_tip_x = cx - rx * 0.6; let tail_outer = Point::new(cx - rx * 0.6, cy + ry * 0.8);
let tail_tip_y = cy + ry * 1.15;
let tail_path = Path::new(|b| { let tail_path = Path::new(|b| {
b.move_to(Point::new(tail_base_x - rx * 0.15, tail_base_y)); b.move_to(tail_inner);
b.line_to(Point::new(tail_tip_x, tail_tip_y)); b.line_to(tail_tip);
b.line_to(Point::new(tail_base_x + rx * 0.15, tail_base_y)); b.line_to(tail_outer);
b.close(); b.close();
}); });
frame.stroke(&tail_path, stroke_style); frame.stroke(&tail_path, stroke_style);
} }
hcie_engine_api::Tool::VectorGear => { hcie_engine_api::Tool::VectorGear => {
// Gear: circle with teeth (small rectangles around perimeter). // Gear — engine: outer_r = r_base*0.9, inner_r = r_base*0.6, hole_r = r_base*0.25
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; // r_base = min(bw, bh)/2
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let r_out = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; let cy = origin_y + ((top + bottom) / 2.0) * self.zoom;
let r_in = r_out * 0.78; 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 teeth = 8;
let segments = 48;
let gear_path = Path::new(|b| { let gear_path = Path::new(|b| {
for i in 0..teeth { for i in 0..(teeth * 2) {
let a0 = (i as f32) * std::f32::consts::TAU / teeth as f32; let a = std::f32::consts::PI * 2.0 * i as f32 / (teeth * 2) as f32
let a1 = (i as f32 + 0.35) * std::f32::consts::TAU / teeth as f32; - std::f32::consts::PI / 2.0;
let a2 = (i as f32 + 0.65) * std::f32::consts::TAU / teeth as f32; let r = if i % 2 == 0 { outer_r } else { inner_r };
let a3 = ((i + 1) as f32) * std::f32::consts::TAU / teeth as f32; let px = cx + r * a.cos();
let p0 = Point::new(cx + r_in * a0.cos(), cy + r_in * a0.sin()); let py = cy + r * a.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());
if i == 0 { if i == 0 {
b.move_to(p0); b.move_to(Point::new(px, py));
} else { } 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(); b.close();
}); });
frame.stroke(&gear_path, stroke_style); frame.stroke(&gear_path, stroke_style);
} // Center hole
hcie_engine_api::Tool::VectorCross => { let hole_path = Path::new(|b| {
// Cross: plus sign shape (horizontal + vertical bars). let segments = 36;
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)
for i in 0..=segments { for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU; let t = i as f32 / segments as f32 * std::f32::consts::TAU;
let px = cx + r * t.cos(); let px = cx + hole_r * t.cos();
let py = cy + r * t.sin(); let py = cy + hole_r * t.sin();
if i == 0 { if i == 0 {
b.move_to(Point::new(px, py)); b.move_to(Point::new(px, py));
} else { } else {
@@ -972,78 +1085,151 @@ impl canvas::Program<Message> for OverlayProgram {
} }
b.close(); 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); 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 => { hcie_engine_api::Tool::VectorBolt => {
// Bolt: zigzag lightning bolt shape. // Bolt — engine: classic top-right to bottom-left zigzag lightning bolt.
let left = origin_x + x0.min(x1) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let right = origin_x + x0.max(x1) * self.zoom; let cy = origin_y + ((top + bottom) / 2.0) * self.zoom;
let top = origin_y + y0.min(y1) * self.zoom; let nx = (bw * 0.18).max(1.0) * self.zoom;
let bottom = origin_y + y0.max(y1) * self.zoom;
let hw = (right - left) / 2.0;
let bolt_path = Path::new(|b| { let bolt_path = Path::new(|b| {
b.move_to(Point::new(left + hw * 0.4, top)); b.move_to(Point::new(cx + nx, origin_y + top * self.zoom));
b.line_to(Point::new(left + hw * 0.8, top)); b.line_to(Point::new(
b.line_to(Point::new(left + hw * 0.55, top + (bottom - top) * 0.42)); origin_x + right * self.zoom,
b.line_to(Point::new(left + hw * 0.85, top + (bottom - top) * 0.42)); origin_y + (top + bh * 0.28) * self.zoom,
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(cx - nx, cy - bh * 0.05 * self.zoom));
b.line_to(Point::new(left + hw * 0.15, top + (bottom - top) * 0.55)); 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(); b.close();
}); });
frame.stroke(&bolt_path, stroke_style); frame.stroke(&bolt_path, stroke_style);
} }
hcie_engine_api::Tool::VectorArrow4 => { hcie_engine_api::Tool::VectorArrow4 => {
// 4-way arrow: up/down/left/right from center. // Arrow4 — engine: cross-shaped body with 4 arrowheads.
let cx = origin_x + ((x0 + x1) / 2.0) * self.zoom; let cx = origin_x + ((left + right) / 2.0) * self.zoom;
let cy = origin_y + ((y0 + y1) / 2.0) * self.zoom; let cy = origin_y + ((top + bottom) / 2.0) * self.zoom;
let hw = ((x1 - x0).abs() / 2.0).max(1.0) * self.zoom; let arm_w = (bw * 0.125).max(1.0) * self.zoom;
let hh = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom; let arm_h = (bh * 0.125).max(1.0) * self.zoom;
let arm = hw * 0.25; 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| { let arrow4_path = Path::new(|b| {
// Up arrow // Up arm with arrowhead
b.move_to(Point::new(cx - arm, cy)); b.move_to(Point::new(cx - arm_w, cy - arm_h));
b.line_to(Point::new(cx - arm, cy - hh + arm)); b.line_to(Point::new(cx - arm_w, top_scr + head_extend_y));
b.line_to(Point::new(cx - arm * 2.0, cy - hh + arm)); b.line_to(Point::new(cx, top_scr));
b.line_to(Point::new(cx, cy - hh)); b.line_to(Point::new(cx + arm_w, top_scr + head_extend_y));
b.line_to(Point::new(cx + arm * 2.0, cy - hh + arm)); b.line_to(Point::new(cx + arm_w, cy - arm_h));
b.line_to(Point::new(cx + arm, cy - hh + arm)); // Right arm with arrowhead
b.line_to(Point::new(cx + arm, cy)); b.line_to(Point::new(right_scr - head_extend_x, cy - arm_h));
// Right arrow b.line_to(Point::new(right_scr, cy));
b.line_to(Point::new(cx + hw - arm, cy)); b.line_to(Point::new(right_scr - head_extend_x, cy + arm_h));
b.line_to(Point::new(cx + hw - arm, cy - arm * 2.0)); b.line_to(Point::new(cx + arm_w, cy + arm_h));
b.line_to(Point::new(cx + hw, cy)); // Down arm with arrowhead
b.line_to(Point::new(cx + hw - arm, cy + arm * 2.0)); b.line_to(Point::new(cx + arm_w, bottom_scr - head_extend_y));
b.line_to(Point::new(cx + hw - arm, cy)); b.line_to(Point::new(cx, bottom_scr));
// Down arrow b.line_to(Point::new(cx - arm_w, bottom_scr - head_extend_y));
b.line_to(Point::new(cx + arm, cy)); b.line_to(Point::new(cx - arm_w, cy + arm_h));
b.line_to(Point::new(cx + arm, cy + hh - arm)); // Left arm with arrowhead
b.line_to(Point::new(cx + arm * 2.0, cy + hh - arm)); b.line_to(Point::new(left_scr + head_extend_x, cy + arm_h));
b.line_to(Point::new(cx, cy + hh)); b.line_to(Point::new(left_scr, cy));
b.line_to(Point::new(cx - arm * 2.0, cy + hh - arm)); b.line_to(Point::new(left_scr + head_extend_x, cy - arm_h));
b.line_to(Point::new(cx - arm, cy + hh - arm)); b.line_to(Point::new(cx - arm_w, cy - arm_h));
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));
b.close(); b.close();
}); });
frame.stroke(&arrow4_path, stroke_style); frame.stroke(&arrow4_path, stroke_style);
@@ -1110,6 +1296,17 @@ impl canvas::Program<Message> for OverlayProgram {
origin_y, origin_y,
state.hovered_vector_handle, 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. // Draw gradient endpoint preview line.
@@ -1414,22 +1611,33 @@ pub fn view<'a>(
// ── Overlay canvas (selection, vector preview, crosshair, pressure HUD) ────── // ── Overlay canvas (selection, vector preview, crosshair, pressure HUD) ──────
// Compute selected vector shape bounds for handle rendering. // Compute selected vector shape bounds for handle rendering.
let (sel_vec_bounds, sel_vec_angle) = if let Some(idx) = doc.selected_vector_shape { // During a vector transform drag, show the preview shape instead of the
let layer_id = doc.engine.active_layer_id(); // engine's committed shape so the overlay stays in sync with the pointer
if let Some(shapes) = doc.engine.active_vector_shapes() { // without paying for per-frame engine rasterization.
if let Some(shape) = shapes.get(idx) { let (sel_vec_bounds, sel_vec_angle, vector_preview) =
let (x1, y1, x2, y2) = shape.normalized_bounds(); if let Some(preview) = doc.vector_drag_preview.as_ref() {
let angle = doc.engine.vector_shape_angle(layer_id, idx); let (x1, y1, x2, y2) = preview.normalized_bounds();
(Some((x1, y1, x2, y2)), angle) (
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 { } else {
(None, 0.0) (None, 0.0, None)
} }
} else { } else {
(None, 0.0) (None, 0.0, None)
} };
} else {
(None, 0.0)
};
let overlay_program = OverlayProgram { let overlay_program = OverlayProgram {
engine_w, engine_w,
@@ -1458,6 +1666,7 @@ pub fn view<'a>(
selected_vector_bounds: sel_vec_bounds, selected_vector_bounds: sel_vec_bounds,
selected_vector_angle: sel_vec_angle, selected_vector_angle: sel_vec_angle,
vector_edit_handle: doc.vector_edit_handle, vector_edit_handle: doc.vector_edit_handle,
vector_drag_preview: vector_preview,
}; };
let overlay_canvas = canvas(overlay_program) let overlay_canvas = canvas(overlay_program)
@@ -641,7 +641,13 @@ impl CanvasShaderPipeline {
/// * `mask` — selection mask bytes (1 byte per pixel, >128 = selected) /// * `mask` — selection mask bytes (1 byte per pixel, >128 = selected)
/// * `canvas_w` — mask width in pixels /// * `canvas_w` — mask width in pixels
/// * `canvas_h` — mask height 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 { if mask.len() != (canvas_w * canvas_h) as usize {
log::warn!( log::warn!(
"[CanvasShaderPipeline::upload_selection_mask] Size mismatch: mask={} but canvas={}×{}={}", "[CanvasShaderPipeline::upload_selection_mask] Size mismatch: mask={} but canvas={}×{}={}",
@@ -821,7 +827,11 @@ impl shader::Primitive for CanvasShaderPrimitive {
viewport_h, viewport_h,
checker_size: CHECKER_SQUARE, checker_size: CHECKER_SQUARE,
anim_time: self.anim_time, 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 }, quick_mask: if self.quick_mask { 1.0 } else { 0.0 },
}; };
@@ -275,9 +275,9 @@ pub fn popup_view(
color, color,
output: WheelOutput::Popup, output: WheelOutput::Popup,
}) })
.width(Length::Fixed(220.0)) .width(Length::Fixed(220.0))
.height(Length::Fixed(220.0)) .height(Length::Fixed(220.0))
.into(), .into(),
_ => hsl_popup_section(color, h, s, l), _ => hsl_popup_section(color, h, s, l),
}; };
@@ -584,18 +584,12 @@ impl canvas::Program<Message> for ColorWheel {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
state.dragging = true; state.dragging = true;
return ( return (canvas::event::Status::Captured, Some(self.message(color)));
canvas::event::Status::Captured,
Some(self.message(color)),
);
} }
} }
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
return ( return (canvas::event::Status::Captured, Some(self.message(color)));
canvas::event::Status::Captured,
Some(self.message(color)),
);
} }
} }
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { 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) .width(Length::Fill)
.height(Length::Fixed(220.0)); .height(Length::Fixed(220.0));
column![wheel, readout] column![wheel, readout].spacing(3).into()
.spacing(3)
.into()
} }
/// Build the HSL sliders view (tab H). /// Build the HSL sliders view (tab H).
@@ -5,8 +5,9 @@
use crate::app::Message; use crate::app::Message;
use crate::dock::state::PaneType; use crate::dock::state::PaneType;
use crate::panels::styles;
use crate::theme::ThemeColors; 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}; use iced::{Element, Length};
/// Renders the fixed right auto-hide rail outside PaneGrid. /// 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); let mut tabs = column![].spacing(3).padding([4, 2]).width(36);
for panel in panels { for panel in panels {
let tab = button(text(panel.icon_glyph()).size(16)) let tab = button(text(panel.icon_glyph()).size(16))
.on_press(Message::AutoHideActivate(panel)) .on_press(Message::AutoHideActivate(panel))
.width(32) .width(32)
.height(32) .height(32)
.padding(6) .padding(6)
.style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status)); .style(move |_theme, status| crate::dock::view::chrome_button_style(colors, status));
tabs = tabs.push(tooltip( tabs = tabs.push(styles::balloon_tooltip(
tab, tab,
text(panel.label()).size(11), panel.label(),
tooltip::Position::Left, iced::widget::tooltip::Position::Left,
colors,
)); ));
} }
container(tabs) container(tabs)
@@ -48,15 +50,16 @@ pub fn overlay<'a>(
text(panel.icon_glyph()).size(14).color(colors.accent), text(panel.icon_glyph()).size(14).color(colors.accent),
text(panel.label()).size(12).color(colors.text_primary), text(panel.label()).size(12).color(colors.text_primary),
iced::widget::Space::with_width(Length::Fill), iced::widget::Space::with_width(Length::Fill),
tooltip( styles::balloon_tooltip(
button(text("").size(10)) button(text("").size(10))
.on_press(Message::AutoHideRestore(panel)) .on_press(Message::AutoHideRestore(panel))
.padding([3, 6]) .padding([3, 6])
.style( .style(
move |_theme, status| crate::dock::view::chrome_button_style(colors, status) move |_theme, status| crate::dock::view::chrome_button_style(colors, status)
), ),
text("Pin panel open").size(11), "Pin panel open",
tooltip::Position::Left iced::widget::tooltip::Position::Left,
colors,
), ),
button(text("×").size(14)) button(text("×").size(14))
.on_press(Message::AutoHideDismiss) .on_press(Message::AutoHideDismiss)
@@ -7,8 +7,9 @@ use crate::app::Message;
use crate::dock::manager::DockRect; use crate::dock::manager::DockRect;
use crate::dock::persistence::PersistedFloatingPanel; use crate::dock::persistence::PersistedFloatingPanel;
use crate::dock::state::PaneType; use crate::dock::state::PaneType;
use crate::panels::styles;
use crate::theme::ThemeColors; 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}; use iced::{Element, Length, Point};
pub const DEFAULT_WIDTH: f32 = 320.0; pub const DEFAULT_WIDTH: f32 = 320.0;
@@ -59,20 +60,23 @@ pub fn card<'a>(
}; };
let header = row![ let header = row![
title, title,
tooltip( styles::balloon_tooltip(
control("", Message::FloatingAutoHide(panel)), control("", Message::FloatingAutoHide(panel)),
text("Minimize to side rail").size(11), "Minimize to side rail",
tooltip::Position::Bottom iced::widget::tooltip::Position::Bottom,
colors,
), ),
tooltip( styles::balloon_tooltip(
control("", Message::FloatingRedock(panel)), control("", Message::FloatingRedock(panel)),
text("Redock panel").size(11), "Redock panel",
tooltip::Position::Bottom iced::widget::tooltip::Position::Bottom,
colors,
), ),
tooltip( styles::balloon_tooltip(
control("×", Message::FloatingClose(panel)), control("×", Message::FloatingClose(panel)),
text("Close panel").size(11), "Close panel",
tooltip::Position::Bottom iced::widget::tooltip::Position::Bottom,
colors,
), ),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
@@ -100,15 +100,11 @@ fn outer_edge(point: Point, bounds: Rectangle) -> Option<DockEdge> {
let thickness = bounds.width.min(bounds.height) / OUTER_THICKNESS_RATIO; let thickness = bounds.width.min(bounds.height) / OUTER_THICKNESS_RATIO;
if point.x > bounds.x && point.x < bounds.x + thickness { if point.x > bounds.x && point.x < bounds.x + thickness {
Some(DockEdge::Left) Some(DockEdge::Left)
} else if point.x > bounds.x + bounds.width - thickness } else if point.x > bounds.x + bounds.width - thickness && point.x < bounds.x + bounds.width {
&& point.x < bounds.x + bounds.width
{
Some(DockEdge::Right) Some(DockEdge::Right)
} else if point.y > bounds.y && point.y < bounds.y + thickness { } else if point.y > bounds.y && point.y < bounds.y + thickness {
Some(DockEdge::Top) Some(DockEdge::Top)
} else if point.y > bounds.y + bounds.height - thickness } else if point.y > bounds.y + bounds.height - thickness && point.y < bounds.y + bounds.height {
&& point.y < bounds.y + bounds.height
{
Some(DockEdge::Bottom) Some(DockEdge::Bottom)
} else { } else {
None None
@@ -260,8 +256,14 @@ mod tests {
#[test] #[test]
fn outer_edge_uses_iced_dynamic_thickness_and_priority() { fn outer_edge_uses_iced_dynamic_thickness_and_priority() {
let bounds = Rectangle::new(Point::ORIGIN, Size::new(500.0, 250.0)); 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(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)
);
} }
} }
@@ -303,18 +303,11 @@ fn window_resize_ratio(
} }
/// Converts child constraints to a legal split interval and clamps a proposed ratio. /// Converts child constraints to a legal split interval and clamps a proposed ratio.
fn clamp_ratio( fn clamp_ratio(requested: f32, total: f32, a: PaneAxisPolicy, b: PaneAxisPolicy) -> f32 {
requested: f32,
total: f32,
a: PaneAxisPolicy,
b: PaneAxisPolicy,
) -> f32 {
let total = total.max(1.0); let total = total.max(1.0);
let half_spacing = PANE_SPACING / 2.0; let half_spacing = PANE_SPACING / 2.0;
let mut low = ((a.min + half_spacing) / total) let mut low = ((a.min + half_spacing) / total).max(1.0 - (b.max + 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 high = (1.0 - (b.min + half_spacing) / total)
.min((a.max + half_spacing) / total);
low = low.clamp(0.01, 0.99); low = low.clamp(0.01, 0.99);
high = high.clamp(0.01, 0.99); high = high.clamp(0.01, 0.99);
if low > high { if low > high {
@@ -362,9 +355,7 @@ fn split_extent(extent: Size, axis: Axis, ratio: f32) -> (Size, Size) {
) )
} }
Axis::Vertical => { Axis::Vertical => {
let a = (extent.width * ratio - PANE_SPACING / 2.0) let a = (extent.width * ratio - PANE_SPACING / 2.0).max(0.0).round();
.max(0.0)
.round();
( (
Size::new(a, extent.height), Size::new(a, extent.height),
Size::new((extent.width - a - PANE_SPACING).max(0.0), 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. /// Compares Iced axes without requiring assumptions about their debug representation.
fn same_axis(a: Axis, b: Axis) -> bool { 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. /// Adds maximum extents while preserving an unbounded result.
@@ -436,7 +430,10 @@ mod tests {
let color = panel_size_policy(PaneType::ColorPicker); let color = panel_size_policy(PaneType::ColorPicker);
assert_eq!((canvas.width.min, canvas.height.min), (320.0, 240.0)); assert_eq!((canvas.width.min, canvas.height.min), (320.0, 240.0));
assert!(canvas.width.max.is_infinite()); 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_eq!((color.width.max, color.height.max), (380.0, 380.0));
assert!(canvas.width.grow_priority > color.width.grow_priority); assert!(canvas.width.grow_priority > color.width.grow_priority);
} }
@@ -457,7 +454,12 @@ mod tests {
fn default_and_compact_viewports_have_feasible_bounds() { fn default_and_compact_viewports_have_feasible_bounds() {
for size in [Size::new(1206.0, 724.0), Size::new(950.0, 524.0)] { for size in [Size::new(1206.0, 724.0), Size::new(950.0, 524.0)] {
let mut dock = crate::dock::state::DockState::new(); 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); let regions = dock.pane_grid.layout().pane_regions(PANE_SPACING, size);
for (pane, panel) in dock.pane_grid.iter() { for (pane, panel) in dock.pane_grid.iter() {
let region = regions[pane]; let region = regions[pane];
@@ -475,8 +477,16 @@ mod tests {
let size = Size::new(300.0, 800.0); let size = Size::new(300.0, 800.0);
rebalance_state(&mut state, size, None, RebalanceMode::InitialDefault); rebalance_state(&mut state, size, None, RebalanceMode::InitialDefault);
let regions = state.layout().pane_regions(PANE_SPACING, size); let regions = state.layout().pane_regions(PANE_SPACING, size);
let color = state.iter().find(|(_, panel)| **panel == PaneType::ColorPicker).unwrap().0; let color = state
let layers = state.iter().find(|(_, panel)| **panel == PaneType::Layers).unwrap().0; .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[color].height <= 380.1);
assert!(regions[layers].height > regions[color].height); assert!(regions[layers].height > regions[color].height);
} }
@@ -491,7 +501,10 @@ mod tests {
None, None,
RebalanceMode::StructureChanged, 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); assert!(ratio > 0.6);
} }
@@ -512,7 +525,11 @@ mod tests {
let old = Size::new(900.0, 700.0); let old = Size::new(900.0, 700.0);
rebalance_state(&mut state, old, None, RebalanceMode::InitialDefault); rebalance_state(&mut state, old, None, RebalanceMode::InitialDefault);
let old_regions = state.layout().pane_regions(PANE_SPACING, old); 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 old_utility = old_regions[&layers].width;
let new = Size::new(1200.0, 700.0); let new = Size::new(1200.0, 700.0);
rebalance_state(&mut state, new, Some(old), RebalanceMode::WindowResize); rebalance_state(&mut state, new, Some(old), RebalanceMode::WindowResize);
@@ -728,11 +728,10 @@ impl DockState {
self.floating.insert(index, item); self.floating.insert(index, item);
return false; return false;
}; };
let Some((new_pane, _)) = self.pane_grid.split( let Some((new_pane, _)) =
iced::widget::pane_grid::Axis::Vertical, self.pane_grid
canvas, .split(iced::widget::pane_grid::Axis::Vertical, canvas, panel)
panel, else {
) else {
self.floating.insert(index, item); self.floating.insert(index, item);
return false; return false;
}; };
@@ -94,22 +94,21 @@ pub fn dock_view<'a>(
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into(); .into();
let base: Element<'a, Message> = let base: Element<'a, Message> = if let Some(preview) = dock
if let Some(preview) = dock .drag
.drag .as_ref()
.as_ref() .and_then(|drag| drag.accepted_preview)
.and_then(|drag| drag.accepted_preview) .or_else(|| dock.floating_target.map(|target| target.preview))
.or_else(|| dock.floating_target.map(|target| target.preview)) {
{ iced::widget::Stack::new()
iced::widget::Stack::new() .push(base)
.push(base) .push(preview_overlay(preview, colors))
.push(preview_overlay(preview, colors)) .width(Length::Fill)
.width(Length::Fill) .height(Length::Fill)
.height(Length::Fill) .into()
.into() } else {
} else { base
base };
};
if let Some(panel) = dock.active_auto_hide { if let Some(panel) = dock.active_auto_hide {
let overlay = let overlay =
crate::dock::auto_hide::overlay(panel, panel_body(panel, app, colors), colors); crate::dock::auto_hide::overlay(panel, panel_body(panel, app, colors), colors);
@@ -133,11 +132,11 @@ pub fn panel_body<'a>(
colors: ThemeColors, colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
match &pane_type { match &pane_type {
PaneType::Canvas => { PaneType::Canvas => {
if app.show_welcome { if app.show_welcome {
return crate::dock::welcome::view(colors); return crate::dock::welcome::view(colors);
} }
// Canvas pane includes document tab bar as its header // Canvas pane includes document tab bar as its header
let doc_tab_bar = document_tab_bar(app, colors); let doc_tab_bar = document_tab_bar(app, colors);
let canvas = { let canvas = {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
@@ -238,6 +237,7 @@ pub fn panel_body<'a>(
ts2.vector_radius, ts2.vector_radius,
ts2.vector_points, ts2.vector_points,
ts2.vector_sides, ts2.vector_sides,
ts2.vector_angle,
active_id, active_id,
app.fg_color, app.fg_color,
app.bg_color, app.bg_color,
@@ -314,7 +314,7 @@ fn document_tab_bar<'a>(
let tab = button( let tab = button(
row![ row![
tab_text, tab_text,
iced::widget::tooltip( styles::balloon_tooltip(
button(text("×").size(10)) button(text("×").size(10))
.on_press(Message::DocClose(idx)) .on_press(Message::DocClose(idx))
.padding([0, 4]) .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, iced::widget::tooltip::Position::Bottom,
colors,
), ),
] ]
.spacing(4) .spacing(4)
@@ -500,25 +501,29 @@ fn make_title_bar<'a>(
.padding([3, 6]) .padding([3, 6])
.style(move |_theme, status| chrome_button_style(colors, status)) .style(move |_theme, status| chrome_button_style(colors, status))
}; };
let pin = iced::widget::tooltip( let pin = styles::balloon_tooltip(
control("", Message::PaneAutoHide(pane)), control("", Message::PaneAutoHide(pane)),
text("Minimize to side rail").size(11), "Minimize to side rail",
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors,
); );
let float_control = iced::widget::tooltip( let float_control = styles::balloon_tooltip(
control("", Message::PaneFloat(pane)), control("", Message::PaneFloat(pane)),
text("Float panel").size(11), "Float panel",
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors,
); );
let maximize = iced::widget::tooltip( let maximize = styles::balloon_tooltip(
control("", Message::PaneMaximize(pane)), control("", Message::PaneMaximize(pane)),
text("Maximize / restore").size(11), "Maximize / restore",
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors,
); );
let close = iced::widget::tooltip( let close = styles::balloon_tooltip(
control("×", Message::PaneClose(pane)), control("×", Message::PaneClose(pane)),
text("Close panel").size(11), "Close panel",
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors,
); );
let compact_control = |label, message| { let compact_control = |label, message| {
button(text(label).size(11)) button(text(label).size(11))
@@ -526,25 +531,29 @@ fn make_title_bar<'a>(
.padding([3, 3]) .padding([3, 3])
.style(move |_theme, status| chrome_button_style(colors, status)) .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)), compact_control("", Message::PaneAutoHide(pane)),
text("Minimize to side rail").size(11), "Minimize to side rail",
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors,
); );
let compact_float = iced::widget::tooltip( let compact_float = styles::balloon_tooltip(
compact_control("", Message::PaneFloat(pane)), compact_control("", Message::PaneFloat(pane)),
text("Float panel").size(11), "Float panel",
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors,
); );
let compact_maximize = iced::widget::tooltip( let compact_maximize = styles::balloon_tooltip(
compact_control("", Message::PaneMaximize(pane)), compact_control("", Message::PaneMaximize(pane)),
text("Maximize / restore").size(11), "Maximize / restore",
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors,
); );
let compact_close = iced::widget::tooltip( let compact_close = styles::balloon_tooltip(
compact_control("×", Message::PaneClose(pane)), compact_control("×", Message::PaneClose(pane)),
text("Close panel").size(11), "Close panel",
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors,
); );
let full_controls = row![pin, float_control, maximize, close] let full_controls = row![pin, float_control, maximize, close]
.spacing(1) .spacing(1)
@@ -29,10 +29,7 @@ pub fn view(colors: ThemeColors) -> Element<'static, Message> {
.size(13) .size(13)
.color(colors.text_secondary), .color(colors.text_secondary),
row![ row![
action( action("New Image", Message::DialogOpen(ActiveDialog::NewImage)),
"New Image",
Message::DialogOpen(ActiveDialog::NewImage)
),
action("Open Image", Message::OpenFileRfd), action("Open Image", Message::OpenFileRfd),
] ]
.spacing(10), .spacing(10),
@@ -350,10 +350,11 @@ pub fn view(
}, },
}, },
); );
tabs = tabs.push(iced::widget::tooltip( tabs = tabs.push(styles::balloon_tooltip(
btn, 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, iced::widget::tooltip::Position::Bottom,
colors,
)); ));
} }
@@ -107,6 +107,7 @@ pub fn view<'a>(
vector_radius: f32, vector_radius: f32,
vector_points: u32, vector_points: u32,
vector_sides: u32, vector_sides: u32,
vector_angle: f32,
active_layer_id: u64, active_layer_id: u64,
foreground_color: [u8; 4], foreground_color: [u8; 4],
background_color: [u8; 4], background_color: [u8; 4],
@@ -131,7 +132,7 @@ pub fn view<'a>(
} }
Tool::Select => selection_properties(selection_rect, colors), Tool::Select => selection_properties(selection_rect, colors),
Tool::VectorSelect => { 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::VectorRect
| Tool::VectorCircle | Tool::VectorCircle
@@ -252,6 +253,7 @@ fn layer_info_section<'a>(
fn vector_select_properties<'a>( fn vector_select_properties<'a>(
selected: Option<usize>, selected: Option<usize>,
shapes: &[VectorShape], shapes: &[VectorShape],
vector_angle: f32,
_colors: ThemeColors, _colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) { match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) {
@@ -315,6 +317,16 @@ fn vector_select_properties<'a>(
}) })
.step(0.01) .step(0.01)
.width(Length::Fill), .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![ row![
text("Fill").size(BODY), text("Fill").size(BODY),
checkbox("", filled) checkbox("", filled)
@@ -96,6 +96,9 @@ pub struct ToolSettings {
/// Text angle in degrees. /// Text angle in degrees.
#[serde(default)] #[serde(default)]
pub text_angle: f32, 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. /// Whether the Text tool commits to a new layer each time.
#[serde(default = "default_apply_to_new_layer")] #[serde(default = "default_apply_to_new_layer")]
pub tool_apply_to_new_layer: bool, pub tool_apply_to_new_layer: bool,
@@ -201,6 +204,7 @@ impl Default for ToolSettings {
selection_anti_alias: true, selection_anti_alias: true,
text_font: "Arial".to_string(), text_font: "Arial".to_string(),
text_angle: 0.0, text_angle: 0.0,
vector_angle: 0.0,
tool_apply_to_new_layer: true, tool_apply_to_new_layer: true,
last_active_tool: "Brush".to_string(), last_active_tool: "Brush".to_string(),
} }
@@ -82,6 +82,13 @@ pub fn sync_shape_from_tool(app: &mut HcieIcedApp) {
.engine .engine
.set_vector_shape_hardness(layer_id, shape_idx, app.tool_state.brush_hardness); .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) // Primary color (stroke color)
app.documents[app.active_doc] app.documents[app.active_doc]
.engine .engine
@@ -137,6 +144,7 @@ pub fn sync_tool_from_shape(app: &mut HcieIcedApp) {
ts.vector_opacity = shape.shape_opacity(); ts.vector_opacity = shape.shape_opacity();
app.tool_state.brush_hardness = shape.hardness(); app.tool_state.brush_hardness = shape.hardness();
ts.vector_fill = shape.is_filled(); ts.vector_fill = shape.is_filled();
ts.vector_angle = shape.angle().to_degrees();
// Shape-specific properties // Shape-specific properties
match shape { match shape {
@@ -163,7 +163,12 @@ pub fn view<'a>(
expanded: bool, expanded: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let sidebar_width = if expanded { 72 } else { 36 }; 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 // Tool buttons — single column or 2-column grid depending on expanded state
let mut tool_list = column![].spacing(1); let mut tool_list = column![].spacing(1);
@@ -261,24 +266,26 @@ pub fn view<'a>(
}); });
let fg_color_val = *fg_color; 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)), 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, iced::widget::tooltip::Position::Right,
colors,
); );
// Make the background swatch clickable so a click promotes the background // Make the background swatch clickable so a click promotes the background
// color to the foreground (egui: toolbox.rs:355-358). // color to the foreground (egui: toolbox.rs:355-358).
let bg_color_val = *bg_color; 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)), 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, iced::widget::tooltip::Position::Right,
colors,
); );
// Small reset-to-defaults button (black/white) below the swatches // 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). // (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)) button(text("D").size(10).color(colors.text_secondary))
.on_press(Message::ResetColors) .on_press(Message::ResetColors)
.padding([1, 3]) .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, iced::widget::tooltip::Position::Right,
colors,
); );
// Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap) // Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap)
@@ -321,7 +329,7 @@ pub fn view<'a>(
right: 0.0, right: 0.0,
})) }))
.push( .push(
container(iced::widget::tooltip( container(styles::balloon_tooltip(
button(text("").size(10).color(colors.text_primary)) button(text("").size(10).color(colors.text_primary))
.on_press(Message::SwapColors) .on_press(Message::SwapColors)
.padding(1) .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, iced::widget::tooltip::Position::Right,
colors,
)) ))
.padding(iced::Padding { .padding(iced::Padding {
top: 0.0, top: 0.0,
@@ -586,31 +595,30 @@ fn tool_button<'a>(
let tool_icon_path = tool_icon(tool); let tool_icon_path = tool_icon(tool);
let resolved_path = resolve_icon_path(tool_icon_path); let resolved_path = resolve_icon_path(tool_icon_path);
let popup_icon: Element<'_, Message> = let popup_icon: Element<'_, Message> = if resolved_path.exists() {
if resolved_path.exists() { let handle = svg::Handle::from_path(&resolved_path);
let handle = svg::Handle::from_path(&resolved_path); svg(handle)
svg(handle) .width(18)
.width(18) .height(18)
.height(18) .style(move |_theme, _status| svg::Style {
.style(move |_theme, _status| svg::Style { color: Some(if is_tool_active {
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 {
iced::Color::WHITE iced::Color::WHITE
} else { } else {
c.text_primary 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 // Wrap the icon in a fixed-size, centered container so the popup
// button always occupies 32×28 pixels even when the SVG is still // button always occupies 32×28 pixels even when the SVG is still
@@ -620,9 +628,7 @@ fn tool_button<'a>(
.height(20) .height(20)
.center_x(Length::Fill) .center_x(Length::Fill)
.center_y(Length::Fill); .center_y(Length::Fill);
let item_content = container(icon_container) let item_content = container(icon_container).width(32).height(28);
.width(32)
.height(28);
let item = button( let item = button(
container(item_content) container(item_content)
@@ -645,7 +651,11 @@ fn tool_button<'a>(
})), })),
text_color: c.text_primary, text_color: c.text_primary,
border: iced::Border::default() 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 }), .width(if is_tool_active { 1 } else { 0 }),
..Default::default() ..Default::default()
}, },
@@ -657,7 +667,11 @@ fn tool_button<'a>(
})), })),
text_color: c.text_primary, text_color: c.text_primary,
border: iced::Border::default() 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 }), .width(if is_tool_active { 1 } else { 0 }),
..Default::default() ..Default::default()
}, },
+83 -25
View File
@@ -564,27 +564,34 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) {
} }
} }
Cross { x1, y1, x2, y2, stroke, color, fill_color, fill, opacity, angle, .. } => { 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 (left, top, right, bottom) = normalized_rect(*x1, *y1, *x2, *y2);
let cx = (left + right) / 2.0; let cx = (left + right) / 2.0;
let cy = (top + bottom) / 2.0; let cy = (top + bottom) / 2.0;
let hw = (right - left) * 0.3; let bw = right - left;
let hh = (bottom - top) * 0.3; 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 alpha = (color[3] as f32 * opacity).round() as u8;
let px_color = [color[0], color[1], color[2], alpha]; let px_color = [color[0], color[1], color[2], alpha];
let mut pts = vec![ let mut pts = vec![
(cx - hw, top), // Top arm
(cx + hw, top), (cx - arm, top),
(cx + hw, cy - hh), (cx + arm, top),
(right, cy - hh), (cx + arm, cy - arm),
(right, cy + hh), // Right arm
(cx + hw, cy + hh), (right, cy - arm),
(cx + hw, bottom), (right, cy + arm),
(cx - hw, bottom), // Bottom arm
(cx - hw, cy + hh), (cx + arm, cy + arm),
(left, cy + hh), (cx + arm, bottom),
(left, cy - hh), (cx - arm, bottom),
(cx - hw, cy - hh), // Left arm
(cx - arm, cy + arm),
(left, cy + arm),
(left, cy - arm),
(cx - arm, cy - arm),
]; ];
for p in &mut pts { 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, .. } => { 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 alpha = (color[3] as f32 * opacity).round() as u8;
let px_color = [color[0], color[1], color[2], alpha]; let px_color = [color[0], color[1], color[2], alpha];
let cx = (*x1 + *x2) / 2.0; let cx = (*x1 + *x2) / 2.0;
let cy = (*y1 + *y2) / 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![ 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 { 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]; let px_fill = [fill_color[0], fill_color[1], fill_color[2], fill_alpha];
fill_polygon(layer, &pts, px_fill); 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); for i in 0..pts.len() {
draw_line(layer, pts[1].0, pts[1].1, pts[2].0, pts[2].1, px_color, *stroke, None); let j = (i + 1) % pts.len();
draw_line(layer, pts[2].0, pts[2].1, pts[3].0, pts[3].1, px_color, *stroke, None); 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, .. } => { 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 (left, top, right, bottom) = normalized_rect(*x1, *y1, *x2, *y2);
let cx = (left + right) / 2.0; let cx = (left + right) / 2.0;
let cy = (top + bottom) / 2.0; let cy = (top + bottom) / 2.0;
let alpha = (color[3] as f32 * opacity).round() as u8; let alpha = (color[3] as f32 * opacity).round() as u8;
let px_color = [color[0], color[1], color[2], alpha]; 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![ 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 { 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]; let px_fill = [fill_color[0], fill_color[1], fill_color[2], fill_alpha];
fill_polygon(layer, &pts, px_fill); 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); for i in 0..pts.len() {
draw_line(layer, pts[1].0, pts[1].1, pts[2].0, pts[2].1, px_color, *stroke, None); let j = (i + 1) % pts.len();
draw_line(layer, pts[2].0, pts[2].1, pts[3].0, pts[3].1, px_color, *stroke, None); draw_line(layer, pts[i].0, pts[i].1, pts[j].0, pts[j].1, px_color, *stroke, None);
draw_line(layer, pts[3].0, pts[3].1, pts[0].0, pts[0].1, px_color, *stroke, None); }
} }
FreePath { pts, stroke, color, fill, fill_color, opacity, .. } => { FreePath { pts, stroke, color, fill, fill_color, opacity, .. } => {
let alpha = (color[3] as f32 * opacity).round() as u8; let alpha = (color[3] as f32 * opacity).round() as u8;