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:
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
|
||||
@@ -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