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:
2026-07-15 04:11:48 +03:00
parent 513f4cdb9b
commit 1fd0c5291a
6 changed files with 2142 additions and 57 deletions
+37 -11
View File
@@ -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;