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