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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
# Iced GUI Selection Features Design
## [S1] Problem
The iced GUI migration is missing critical selection-based features that exist in the egui GUI:
1. **#1350 Move/Rotate/Scale** - No transform handles on selections
2. **#1351 Raster Copy/Paste** - Only copies entire canvas, not selected pixels
3. **#1352 Vector Copy/Paste** - No vector shape selection/copy/paste
4. **Selection menu** - Missing feather, grow, shrink, border, fill operations
5. **Crop tool** - Broken (Enter/double-click don't work, selection mixes between images, shows marching ants instead of proper handles)
## [S2] Solution Overview
### Core Architecture Changes
#### 1. SelectionTransform State (in `IcedDocument`)
Add to `IcedDocument`:
```rust
pub struct IcedDocument {
// ... existing fields ...
/// Active selection transform (floating pixels after cut/copy-paste)
pub selection_transform: Option<SelectionTransform>,
/// Transform handle being dragged
pub transform_drag_handle: TransformHandle,
/// Internal clipboard for copy/paste operations
pub internal_clipboard: Option<SelectionTransform>,
/// Selection mask (alpha channel) for the current selection
pub selection_mask: Option<Vec<u8>>,
/// Selection bounds (x, y, width, height)
pub selection_bounds: Option<(u32, u32, u32, u32)>,
}
```
#### 2. SelectionTransform Struct
```rust
#[derive(Clone)]
pub struct SelectionTransform {
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
pub pos: iced::Vector,
pub size: iced::Vector,
pub rotation: f32, // radians
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TransformHandle {
#[default]
None,
Move,
TopLeft,
TopRight,
BottomRight,
BottomLeft,
Top,
Bottom,
Left,
Right,
Rotate, // rotation handle (outside corner)
}
```
#### 3. CropState Struct
```rust
#[derive(Clone, Default)]
pub struct CropState {
/// Crop rectangle in canvas coordinates
pub rect: Option<(f32, f32, f32, f32)>,
/// Whether crop is being dragged
pub is_dragging: bool,
/// Drag start position
pub drag_start: Option<(f32, f32)>,
/// Whether to show crop overlay
pub active: bool,
}
```
## [S3] Feature Details
### Feature #1350: Raster Move & Rotate & Scale
**UI Requirements:**
- Selection shows 8 resize handles (corners + edges) + rotation handle
- Handles are 8x8px squares at zoom=1, scaled by 1/zoom
- Rotation handle is a small circle above the top-center handle
- Dotted rectangle around selection when in transform mode
- Cursor changes based on handle (resize arrows, move crosshair, rotation arc)
**Implementation:**
1. Add `selection_transform: Option<SelectionTransform>` to `IcedDocument`
2. Extend `OverlayProgram` to draw transform handles
3. Add `TransformHandle` hit-testing in overlay `update()`
4. Handle mouse drag on each handle type in `app.rs`
5. Apply transform on Enter key or double-click
**Messages to Add:**
```rust
Message::TransformStart(usize), // handle index
Message::TransformMove(f32, f32),
Message::TransformEnd,
Message::TransformApply,
Message::TransformCancel,
Message::TransformRotate(f32),
```
### Feature #1351: Raster Selection Copy & Paste
**UI Requirements:**
- Copy (Ctrl+C) copies only selected pixels (respecting mask)
- Paste (Ctrl+V) creates floating layer with copied pixels
- Cut (Ctrl+X) copies and clears selected pixels
- Internal clipboard stores SelectionTransform
- Paste positions at selection origin or center of viewport
**Implementation:**
1. On Copy: extract pixels within selection mask, store in `internal_clipboard`
2. On Paste: create `SelectionTransform` from clipboard, enter transform mode
3. On Cut: copy then clear selected pixels in active layer
4. On transform apply: composite floating pixels back to layer
**Engine API Calls:**
```rust
// Copy selected pixels
let pixels = engine.get_active_layer_pixels();
let mask = engine.get_selection_mask();
let selected = extract_masked_pixels(pixels, mask, canvas_w, canvas_h);
// Cut
engine.clear_selection(); // may need to add this API
// Paste
engine.add_layer("Floating");
engine.set_active_layer(new_layer_id);
engine.draw_pixels_at(floating_pixels, x, y);
```
### Feature #1352: Vector Selection Copy & Paste
**UI Requirements:**
- VectorSelect tool can click to select vector shapes
- Selected shapes show bounding box with handles
- Copy/Paste duplicates selected vector shapes
- Paste positions at offset from original
**Implementation:**
1. Add `selected_vector_indices: Vec<usize>` to state
2. On VectorSelect click: hit-test vector shapes, select closest
3. On Copy: store selected VectorShape data in clipboard
4. On Paste: add duplicated shapes with offset
5. Draw selection bounding box in overlay
**Messages to Add:**
```rust
Message::VectorSelectAt(f32, f32),
Message::VectorSelectToggle(usize),
Message::VectorCopy,
Message::VectorPaste,
Message::VectorCut,
Message::VectorDelete,
```
### Selection Menu Improvements
**Menu Items to Implement:**
- Select > All (Ctrl+A) - ✅ already exists
- Select > Deselect (Ctrl+D) - ✅ already exists
- Select > Inverse (Shift+Ctrl+I) - needs implementation
- Select > Grow - ✅ dialog exists, needs engine call
- Select > Shrink - needs implementation
- Select > Feather - needs implementation
- Select > Border - needs implementation
- Select > Fill - needs implementation
**Implementation:**
1. Add engine API methods if missing:
- `engine.invert_selection()`
- `engine.shrink_selection(px)`
- `engine.feather_selection(px)`
- `engine.border_selection(px)`
- `engine.fill_selection(color)`
2. Wire menu items to these methods
3. Add dialog for parameter input (px value)
### Crop Tool Fixes
**Issues to Fix:**
1. Enter key not working → Wire keyboard event to crop confirm
2. Double-click not working → Add double-click handler
3. Selection mixing between images → Store crop state per-document
4. Marching ants instead of handles → Draw proper crop handles
**Implementation:**
1. Add `crop_state: CropState` to `IcedDocument` (per-document)
2. Draw crop overlay with 8 handles + rule-of-thirds grid
3. Handle Enter/double-click to confirm crop
4. Handle Escape to cancel crop
5. Apply crop via `engine.crop(x, y, w, h)`
**Crop Overlay Drawing:**
```
┌─────────────────────┐
│ │ │ │ │ ← rule of thirds
│─────┼─────┼─────│ │
│ │ │ │ │
│─────┼─────┼─────│ │
│ │ │ │ │
└─────────────────────┘
□ □ ← corner handles
□ □ ← edge handles
```
## [S4] Files to Modify
### Primary Files
1. `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` - Main state and update logic
2. `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs` - Overlay drawing
3. `hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs` - Menu definitions
4. `hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs` - Tool options
### New Files (if needed)
1. `hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs` - Transform logic
2. `hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs` - Crop tool logic
## [S5] Implementation Order
1. **Phase 1: Core Selection State**
- Add SelectionTransform, TransformHandle, CropState structs
- Add selection_mask, selection_bounds to IcedDocument
- Basic selection drawing improvements
2. **Phase 2: Transform Handles**
- Draw 8 resize handles + rotation handle
- Handle hit-testing for each handle
- Implement drag behavior for resize/move/rotate
3. **Phase 3: Raster Copy/Paste**
- Implement extract_masked_pixels helper
- Wire Ctrl+C/V/X to selection operations
- Add transform mode on paste
4. **Phase 4: Vector Selection**
- Add vector shape hit-testing
- Implement VectorSelect tool behavior
- Wire vector copy/paste
5. **Phase 5: Selection Menu**
- Implement Inverse, Shrink, Feather, Border, Fill
- Add parameter dialogs where needed
6. **Phase 6: Crop Tool**
- Fix Enter/double-click handling
- Draw proper crop overlay with handles
- Per-document crop state isolation
## [S6] Testing Strategy
1. **Unit Tests:**
- SelectionTransform::from_engine() with various masks
- TransformHandle hit-testing accuracy
- extract_masked_pixels() correctness
2. **Integration Tests:**
- Full copy/paste workflow
- Transform apply/cancel cycle
- Crop confirm/cancel cycle
3. **Visual Tests:**
- Screenshot comparison for handle rendering
- Verify cursor changes on hover
- Check overlay alignment at different zoom levels
## [S7] Performance Considerations
- Selection mask should be cached, not recomputed per frame
- Transform preview should use existing composite refresh throttling
- Crop overlay is lightweight (few rectangles) - no performance concern
- Vector hit-testing uses bounding box first, then precise test only on candidates
## [S8] Edge Cases
1. **Empty selection** - All operations should be no-ops or show warning
2. **Selection outside canvas** - Clamp to canvas bounds
3. **Multiple documents** - Each has independent selection state
4. **Undo/redo** - Transform apply should create history entry
5. **Layer visibility** - Selection should only affect visible layers
6. **Vector shapes on hidden layers** - Should not be selectable
+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;