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:
@@ -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()
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user