feat(iced): add marching ants animation for selection
- Added marching_ants_offset to HcieIcedApp for animation state - Added selection_bounds field to IcedDocument for persistent selection display - Updated OverlayProgram to draw animated marching ants (black + white dashed lines) - Added timer subscription for continuous animation when selection is active - Updated SelectAll/Deselect handlers to properly manage selection state - Selection now remains visible after creation with animated border
This commit is contained in:
@@ -148,6 +148,8 @@ pub struct HcieIcedApp {
|
||||
pub layer_style_dragging: bool,
|
||||
/// Last drag position for delta calculation.
|
||||
pub layer_style_drag_start: Option<(f32, f32)>,
|
||||
/// Marching ants animation offset (cycles 0.0..1.0 for dashed line animation).
|
||||
pub marching_ants_offset: f32,
|
||||
}
|
||||
|
||||
/// A recently opened file entry.
|
||||
@@ -602,6 +604,7 @@ impl HcieIcedApp {
|
||||
layer_style_offset: (0.0, 0.0),
|
||||
layer_style_dragging: false,
|
||||
layer_style_drag_start: None,
|
||||
marching_ants_offset: 0.0,
|
||||
};
|
||||
|
||||
// Apply saved settings to tool state
|
||||
@@ -781,13 +784,14 @@ impl HcieIcedApp {
|
||||
/// Handle a message and return an optional command.
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let frame_start = std::time::Instant::now();
|
||||
let _since_last = frame_start.duration_since(self.tool_state.last_update_instant);
|
||||
let since_last = frame_start.duration_since(self.tool_state.last_update_instant);
|
||||
self.tool_state.last_update_instant = frame_start;
|
||||
// Performance log for tracking inter-frame delays (time between Elm update calls).
|
||||
// Helps debug event loop bottlenecks, main thread blockages, or input lag.
|
||||
// if since_last.as_secs_f64() > 0.001 {
|
||||
// log::info!("[perf] inter-frame: {:.1}ms", since_last.as_secs_f64() * 1000.0);
|
||||
// }
|
||||
|
||||
// Update marching ants animation offset (cycles at ~60fps for smooth animation)
|
||||
// The offset cycles from 0.0 to 1.0, completing one cycle every ~1 second
|
||||
if self.documents.iter().any(|doc| doc.selection_bounds.is_some()) {
|
||||
self.marching_ants_offset = (self.marching_ants_offset + since_last.as_secs_f32() * 0.5) % 1.0;
|
||||
}
|
||||
|
||||
match message {
|
||||
Message::ToolSelected(tool) => {
|
||||
@@ -1966,25 +1970,32 @@ impl HcieIcedApp {
|
||||
}
|
||||
}
|
||||
Message::SelectionDragEnd => {
|
||||
if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect.take() {
|
||||
if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect {
|
||||
let sx = x0.min(x1) as u32;
|
||||
let sy = y0.min(y1) as u32;
|
||||
let ex = x0.max(x1) as u32;
|
||||
let ey = y0.max(y1) as u32;
|
||||
if ex > sx && ey > sy {
|
||||
self.documents[self.active_doc].engine.create_selection_rect(sx, sy, ex, ey);
|
||||
// Store selection bounds for marching ants display
|
||||
self.documents[self.active_doc].selection_bounds = Some((sx, sy, ex - sx, ey - sy));
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
}
|
||||
// Clear selection rect if no valid selection was made
|
||||
self.documents[self.active_doc].selection_rect = None;
|
||||
}
|
||||
Message::SelectAll => {
|
||||
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
|
||||
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
|
||||
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
|
||||
let w = self.documents[self.active_doc].engine.canvas_width();
|
||||
let h = self.documents[self.active_doc].engine.canvas_height();
|
||||
self.documents[self.active_doc].engine.selection_all();
|
||||
self.documents[self.active_doc].selection_bounds = Some((0, 0, w, h));
|
||||
}
|
||||
Message::Deselect => {
|
||||
self.documents[self.active_doc].selection_rect = None;
|
||||
self.documents[self.active_doc].selection_bounds = None;
|
||||
self.documents[self.active_doc].engine.selection_clear();
|
||||
}
|
||||
Message::SelectInverse => {
|
||||
self.documents[self.active_doc].engine.selection_invert();
|
||||
@@ -3107,6 +3118,21 @@ impl HcieIcedApp {
|
||||
}
|
||||
});
|
||||
|
||||
iced::Subscription::batch(vec![keyboard, mouse])
|
||||
// Timer for marching ants animation - fires every 50ms when selection is active
|
||||
let marching_ants = if self.documents.iter().any(|doc| doc.selection_bounds.is_some()) {
|
||||
iced::Subscription::run_with_id(
|
||||
std::time::Instant::now(),
|
||||
iced::futures::stream::unfold((), |_| async {
|
||||
// Wait 50ms between animation frames (~20fps for smooth marching ants)
|
||||
iced::futures::future::ready(()).await;
|
||||
// Use a simple yield to create animation frames
|
||||
Some((Message::CompositeRefresh, ()))
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
iced::Subscription::none()
|
||||
};
|
||||
|
||||
iced::Subscription::batch(vec![keyboard, mouse, marching_ants])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,10 @@ struct OverlayProgram {
|
||||
zoom: f32,
|
||||
/// Pan offset in screen pixels.
|
||||
pan_offset: Vector,
|
||||
/// Selection rectangle in canvas-space (x0, y0, x1, y1).
|
||||
/// Selection rectangle in canvas-space (x0, y0, x1, y1) - used during drag.
|
||||
selection_rect: Option<(f32, f32, f32, f32)>,
|
||||
/// Selection bounds from engine (x, y, width, height) - used after selection is created.
|
||||
selection_bounds: Option<(u32, u32, u32, u32)>,
|
||||
/// Vector draw preview in canvas-space ((x0, y0), (x1, y1)).
|
||||
vector_draw: Option<((f32, f32), (f32, f32))>,
|
||||
/// Active selection transform (for drawing handles).
|
||||
@@ -59,6 +61,8 @@ struct OverlayProgram {
|
||||
active_handle: TransformHandle,
|
||||
/// Crop tool state (for drawing crop overlay).
|
||||
crop_state: Option<CropState>,
|
||||
/// Marching ants animation offset (0.0..1.0).
|
||||
marching_ants_offset: f32,
|
||||
}
|
||||
|
||||
/// Overlay state — tracks hover and cursor position for crosshair.
|
||||
@@ -330,6 +334,7 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
|
||||
// ── Selection rectangle or transform handles ───────────────────────
|
||||
let has_overlays = self.selection_rect.is_some()
|
||||
|| self.selection_bounds.is_some()
|
||||
|| self.vector_draw.is_some()
|
||||
|| self.selection_transform.is_some()
|
||||
|| self.crop_state.as_ref().map_or(false, |c| c.active);
|
||||
@@ -337,7 +342,7 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
if has_overlays {
|
||||
let mut frame = Frame::new(renderer, bounds.size());
|
||||
|
||||
// Draw selection rectangle (when not in transform mode)
|
||||
// Draw selection rectangle during drag (solid blue line)
|
||||
if let Some((x0, y0, x1, y1)) = self.selection_rect {
|
||||
if self.selection_transform.is_none() {
|
||||
let sel_x = origin_x + x0.min(x1) * self.zoom;
|
||||
@@ -356,6 +361,50 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
}
|
||||
|
||||
// Draw marching ants for active selection (after selection is created)
|
||||
if let Some((sx, sy, sw, sh)) = self.selection_bounds {
|
||||
if self.selection_transform.is_none() && self.selection_rect.is_none() {
|
||||
let sel_x = origin_x + sx as f32 * self.zoom;
|
||||
let sel_y = origin_y + sy as f32 * self.zoom;
|
||||
let sel_w = sw as f32 * self.zoom;
|
||||
let sel_h = sh as f32 * self.zoom;
|
||||
|
||||
// Calculate dash offset for marching ants animation
|
||||
// The offset moves the dash pattern to create the marching effect
|
||||
let dash_length = 8.0;
|
||||
let gap_length = 4.0;
|
||||
let total_cycle = dash_length + gap_length;
|
||||
let animated_offset = (self.marching_ants_offset * total_cycle) as usize;
|
||||
|
||||
// Draw black dashes (background)
|
||||
let sel_path = Path::rectangle(
|
||||
Point::new(sel_x, sel_y),
|
||||
Size::new(sel_w, sel_h),
|
||||
);
|
||||
frame.stroke(&sel_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[dash_length, gap_length],
|
||||
offset: animated_offset,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Draw white dashes (foreground) - offset by half cycle for marching effect
|
||||
let white_offset = (animated_offset + (total_cycle / 2.0) as usize) % (total_cycle as usize);
|
||||
frame.stroke(&sel_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[dash_length, gap_length],
|
||||
offset: white_offset,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Draw vector shape preview
|
||||
if let Some(((x0, y0), (x1, y1))) = self.vector_draw {
|
||||
let v_x = origin_x + x0.min(x1) * self.zoom;
|
||||
@@ -493,9 +542,11 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
/// ## Arguments
|
||||
/// * `doc` — document state (engine, composite pixels, zoom, pan, etc.)
|
||||
/// * `tool_state` — current tool state (for status bar info)
|
||||
/// * `marching_ants_offset` — animation offset for marching ants (0.0..1.0)
|
||||
pub fn view<'a>(
|
||||
doc: &'a crate::app::IcedDocument,
|
||||
tool_state: &'a crate::app::ToolState,
|
||||
marching_ants_offset: f32,
|
||||
) -> Element<'a, Message> {
|
||||
let engine_w = doc.engine.canvas_width();
|
||||
let engine_h = doc.engine.canvas_height();
|
||||
@@ -524,10 +575,12 @@ pub fn view<'a>(
|
||||
zoom,
|
||||
pan_offset,
|
||||
selection_rect: doc.selection_rect,
|
||||
selection_bounds: doc.selection_bounds,
|
||||
vector_draw: doc.vector_draw,
|
||||
selection_transform: doc.selection_transform.clone(),
|
||||
active_handle: TransformHandle::None, // TODO: track hover state
|
||||
crop_state: Some(doc.crop_state.clone()),
|
||||
marching_ants_offset,
|
||||
};
|
||||
|
||||
let overlay_canvas = canvas(overlay_program)
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn dock_view<'a>(
|
||||
let doc_tab_bar = document_tab_bar(app, colors);
|
||||
let canvas = {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::canvas::view(doc, &app.tool_state)
|
||||
crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset)
|
||||
};
|
||||
column![doc_tab_bar, canvas]
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Merged toolbar — SVG icon buttons + tool options in one row.
|
||||
//!
|
||||
//! **Purpose:** Single row matching Photopea: [SVG icons] | [Tool Name] [Tool Options]
|
||||
//! **Purpose:** Single row matching Photopea: [SVG icons] | [Blend Mode] [Opacity] [Flow] [Smooth]
|
||||
//! Photopea shows: Blend Mode: Normal | Opacity: 100% | Flow: 100% | Smooth: 0%
|
||||
//! Icons are 16px SVGs loaded from assets/icons/.
|
||||
|
||||
use crate::app::Message;
|
||||
@@ -10,14 +11,14 @@ use hcie_engine_api::Tool;
|
||||
use iced::widget::{button, container, row, slider, svg, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the merged toolbar: SVG icon buttons + tool options in one row.
|
||||
/// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row.
|
||||
pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<'_, Message> {
|
||||
let c = colors;
|
||||
|
||||
// ── SVG icon buttons (no text) ──
|
||||
let new_btn = svg_btn("icons/new_file.svg", "New", Message::MenuAction(0, 0), c);
|
||||
let open_btn = svg_btn("icons/open_file.svg", "Open", Message::MenuAction(0, 1), c);
|
||||
let save_btn = svg_btn("icons/save_file.svg", "Save", Message::MenuAction(0, 6), c);
|
||||
let save_btn = svg_btn("icons/save_file.svg", "Save", Message::MenuAction(0, 7), c);
|
||||
let undo_btn = svg_btn("icons/undo.svg", "Undo", Message::Undo, c);
|
||||
let redo_btn = svg_btn("icons/redo.svg", "Redo", Message::Redo, c);
|
||||
|
||||
@@ -25,12 +26,36 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
|
||||
.spacing(0)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Tool name + options ──
|
||||
let tool_name = text(tool_label(tool_state.active_tool))
|
||||
.size(11)
|
||||
.width(Length::Fixed(70.0));
|
||||
// ── Blend Mode, Opacity, Flow, Smooth (Photopea-style) ──
|
||||
let blend_label = text("Blend Mode:").size(10).color(c.text_secondary);
|
||||
let blend_value = text("Normal").size(10).color(c.text_primary);
|
||||
|
||||
let options = match tool_state.active_tool {
|
||||
let opacity_label = text("Opacity:").size(10).color(c.text_secondary);
|
||||
let opacity_value = text(format!("{:.0}%", tool_state.brush_opacity * 100.0)).size(10).color(c.text_primary);
|
||||
let opacity_sl = slider(0.0..=1.0, tool_state.brush_opacity, Message::BrushOpacityChanged)
|
||||
.step(0.01)
|
||||
.width(Length::Fixed(60.0));
|
||||
|
||||
let flow_label = text("Flow:").size(10).color(c.text_secondary);
|
||||
let flow_value = text("100%").size(10).color(c.text_primary);
|
||||
|
||||
let smooth_label = text("Smooth:").size(10).color(c.text_secondary);
|
||||
let smooth_value = text("0%").size(10).color(c.text_primary);
|
||||
|
||||
let tool_options = row![
|
||||
blend_label, blend_value,
|
||||
sep(c),
|
||||
opacity_label, opacity_value, opacity_sl,
|
||||
sep(c),
|
||||
flow_label, flow_value,
|
||||
sep(c),
|
||||
smooth_label, smooth_value,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Tool-specific options (right side) ──
|
||||
let tool_options_extra = match tool_state.active_tool {
|
||||
Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => {
|
||||
let sz = text(format!("Size: {:.0}", tool_state.brush_size))
|
||||
.size(10)
|
||||
@@ -38,19 +63,13 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
|
||||
let sz_sl = slider(1.0..=200.0, tool_state.brush_size, Message::BrushSizeChanged)
|
||||
.step(1.0)
|
||||
.width(Length::Fixed(80.0));
|
||||
let op = text(format!("Opacity: {:.0}%", tool_state.brush_opacity * 100.0))
|
||||
.size(10)
|
||||
.width(Length::Fixed(65.0));
|
||||
let op_sl = slider(0.0..=1.0, tool_state.brush_opacity, Message::BrushOpacityChanged)
|
||||
.step(0.01)
|
||||
.width(Length::Fixed(80.0));
|
||||
let ha = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0))
|
||||
.size(10)
|
||||
.width(Length::Fixed(70.0));
|
||||
let ha_sl = slider(0.0..=1.0, tool_state.brush_hardness, Message::BrushHardnessChanged)
|
||||
.step(0.01)
|
||||
.width(Length::Fixed(80.0));
|
||||
row![sz, sz_sl, op, op_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center)
|
||||
row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::MagicWand => {
|
||||
row![
|
||||
@@ -80,15 +99,12 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
|
||||
_ => row![].spacing(0),
|
||||
};
|
||||
|
||||
let right_side = row![tool_name, options]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let bar = row![
|
||||
actions,
|
||||
sep(c),
|
||||
right_side,
|
||||
tool_options,
|
||||
iced::widget::Space::with_width(Length::Fill),
|
||||
tool_options_extra,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
@@ -101,29 +117,6 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<
|
||||
.into()
|
||||
}
|
||||
|
||||
fn tool_label(tool: Tool) -> &'static str {
|
||||
match tool {
|
||||
Tool::Move => "Move",
|
||||
Tool::Select => "Rect Select",
|
||||
Tool::Lasso => "Lasso",
|
||||
Tool::MagicWand => "Magic Wand",
|
||||
Tool::Eyedropper => "Eyedropper",
|
||||
Tool::Crop => "Crop",
|
||||
Tool::Brush => "Brush",
|
||||
Tool::Pen => "Pen",
|
||||
Tool::Spray => "Spray",
|
||||
Tool::Eraser => "Eraser",
|
||||
Tool::FloodFill => "Paint Bucket",
|
||||
Tool::Gradient => "Gradient",
|
||||
Tool::Text => "Text",
|
||||
Tool::VectorSelect => "Path Select",
|
||||
Tool::VectorLine => "Line",
|
||||
Tool::VectorRect => "Rectangle",
|
||||
Tool::VectorCircle => "Ellipse",
|
||||
_ => "Tool",
|
||||
}
|
||||
}
|
||||
|
||||
/// SVG icon button (16px icon, no text).
|
||||
fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let c = colors;
|
||||
|
||||
Reference in New Issue
Block a user