b2c88e5471
- Updated the canvas composite shader to include a selection mask fill and animated marching ants border. - Added new uniforms for animation time, selection mask presence, and quick mask mode. - Modified the fragment shader to handle selection rendering, including color tinting for selected areas and a dash pattern for the marching ants effect. - Refactored the OverlayProgram to remove the old marching ants rendering logic, now handled by the GPU shader. - Introduced new data structures in the shader pipeline for managing selection mask textures and samplers. - Implemented functionality to upload selection mask data to the GPU. - Updated selection state handling to ensure correct bounds calculations. - Enhanced sidebar tool button rendering with fallback for missing icons.
1661 lines
74 KiB
Rust
1661 lines
74 KiB
Rust
//! Canvas viewport — renders the composite texture with zoom/pan via GPU shader.
|
||
//!
|
||
//! Uses `iced::widget::Shader` with a custom wgpu pipeline (`shader_canvas`)
|
||
//! that uploads only the dirty sub-region of the composite texture per frame.
|
||
//! Overlays (selection rectangle, vector preview) are drawn via a small
|
||
//! `iced::widget::Canvas` layered on top via `iced::widget::Stack`.
|
||
//!
|
||
//! ## Architecture
|
||
//! - **Shader widget** — draws checkerboard + composite texture (1 draw call)
|
||
//! - **Canvas overlay** — draws selection rects, vector previews, crosshair cursor
|
||
//!
|
||
//! ## Performance
|
||
//! The shader widget replaces the previous `ImageHandle::from_rgba()` approach
|
||
//! that copied 33MB per frame. Now only the dirty sub-region (~8KB for a
|
||
//! typical brush dab) is uploaded via `queue.write_texture()`.
|
||
//!
|
||
//! ⚠️ PERFORMANCE-CRITICAL (DO NOT MODIFY):
|
||
//! - The shader pipeline creates the GPU texture once and reuses it
|
||
//! - Only dirty sub-regions are uploaded — never the full 33MB buffer
|
||
//! - The checkerboard is procedural (in the fragment shader) — no CPU geometry
|
||
|
||
pub mod edge_cache;
|
||
pub mod shader_canvas;
|
||
// pub mod viewport;
|
||
// pub mod render;
|
||
|
||
use crate::app::Message;
|
||
use crate::selection::{CropState, SelectionTransform, TransformHandle};
|
||
use iced::mouse::{self, Cursor};
|
||
use iced::widget::canvas::{Frame, Path, Stroke};
|
||
use iced::widget::Shader;
|
||
use iced::widget::{canvas, column, container, row, text, Stack};
|
||
use iced::{Element, Length, Point, Rectangle, Size, Vector};
|
||
|
||
use hcie_engine_api::{ZOOM_MAX, ZOOM_MIN};
|
||
use shader_canvas::CanvasShaderProgram;
|
||
|
||
// ─── Overlay (selection rect, vector preview, crosshair) ─────────────────────
|
||
|
||
/// Overlay program for drawing selection rects, vector previews, and crosshair.
|
||
///
|
||
/// This is a lightweight `canvas::Program` that draws only a few lines/rects.
|
||
/// It is layered on top of the shader canvas via `Stack`.
|
||
#[derive(Debug, Clone)]
|
||
struct OverlayProgram {
|
||
/// Engine canvas width in pixels.
|
||
engine_w: u32,
|
||
/// Engine canvas height in pixels.
|
||
engine_h: u32,
|
||
/// Current zoom level.
|
||
zoom: f32,
|
||
/// Pan offset in screen pixels.
|
||
pan_offset: Vector,
|
||
/// 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).
|
||
selection_transform: Option<SelectionTransform>,
|
||
/// Current transform handle being hovered/dragged.
|
||
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,
|
||
/// Current pen pressure for HUD display (0.0..1.0, 1.0 = no tablet).
|
||
pressure: f32,
|
||
/// Active tool (drives vector shape preview geometry).
|
||
active_tool: hcie_engine_api::Tool,
|
||
/// Number of points for star shape preview.
|
||
star_points: u32,
|
||
/// Number of sides for polygon shape preview.
|
||
polygon_sides: u32,
|
||
/// Corner radius for rectangle shape preview (rounded corners when > 0).
|
||
rect_radius: f32,
|
||
/// Gradient drag preview endpoints in canvas-space.
|
||
gradient_drag: Option<((f32, f32), (f32, f32))>,
|
||
/// Lasso freehand path points accumulated during drag (canvas-space).
|
||
lasso_points: Vec<(u32, u32)>,
|
||
/// Polygon selection points accumulated during click-by-click (canvas-space).
|
||
polygon_points: Vec<(u32, u32)>,
|
||
/// Extracted edges for marching ants rendering.
|
||
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
|
||
/// Exact horizontal selected-pixel spans for irregular selection fill.
|
||
selection_fill_spans: Option<std::sync::Arc<Vec<(u32, u32, u32)>>>,
|
||
/// Whether selected pixels use quick-mask red instead of the normal blue tint.
|
||
quick_mask: bool,
|
||
/// Active brush diameter in canvas pixels for the footprint cursor.
|
||
brush_size: f32,
|
||
/// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2).
|
||
selected_vector_bounds: Option<(f32, f32, f32, f32)>,
|
||
/// Rotation angle (radians) of the selected vector shape.
|
||
selected_vector_angle: f32,
|
||
/// Which vector edit handle is active (hovered or being dragged).
|
||
vector_edit_handle: hcie_engine_api::VectorEditHandle,
|
||
}
|
||
|
||
/// Overlay state — tracks hover and cursor position for crosshair.
|
||
#[derive(Default)]
|
||
struct OverlayState {
|
||
/// Current cursor position in viewport-local coordinates.
|
||
cursor_pos: Option<Point>,
|
||
/// Whether the mouse is over the overlay.
|
||
is_hovered: bool,
|
||
/// Current handle being hovered (for cursor changes).
|
||
hovered_handle: TransformHandle,
|
||
/// Vector handle currently under the pointer.
|
||
hovered_vector_handle: hcie_engine_api::VectorEditHandle,
|
||
}
|
||
|
||
impl OverlayProgram {
|
||
/// Compute canvas origin in pane-relative coords (for overlay drawing).
|
||
fn canvas_origin(&self, pane: Size) -> (f32, f32, f32, f32) {
|
||
let engine_w = self.engine_w as f32;
|
||
let engine_h = self.engine_h as f32;
|
||
let display_w = engine_w * self.zoom;
|
||
let display_h = engine_h * self.zoom;
|
||
let raw_x = (pane.width - display_w) / 2.0 + self.pan_offset.x;
|
||
let raw_y = (pane.height - display_h) / 2.0 + self.pan_offset.y;
|
||
let min_vis_w = display_w * 0.25;
|
||
let min_vis_h = display_h * 0.25;
|
||
let ox = raw_x.clamp(
|
||
-(display_w - min_vis_w).max(0.0),
|
||
(pane.width - min_vis_w).max(0.0),
|
||
);
|
||
let oy = raw_y.clamp(
|
||
-(display_h - min_vis_h).max(0.0),
|
||
(pane.height - min_vis_h).max(0.0),
|
||
);
|
||
(ox, oy, display_w, display_h)
|
||
}
|
||
|
||
/// Computes cursor-anchored wheel zoom using the same clamped viewport model as rendering.
|
||
///
|
||
/// **Arguments:** `bounds` is the overlay viewport, `cursor` is viewport-local, and `factor`
|
||
/// is the multiplicative wheel step. **Returns:** The new zoom and pan offset preserving the
|
||
/// canvas point beneath the cursor. **Side Effects / Dependencies:** None.
|
||
fn wheel_zoom(&self, bounds: Rectangle, cursor: Point, factor: f32) -> (f32, Vector) {
|
||
let old_zoom = self.zoom.max(0.000_1);
|
||
let new_zoom = (old_zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
||
let engine_w = self.engine_w as f32;
|
||
let engine_h = self.engine_h as f32;
|
||
let old_origin_x = (bounds.width - engine_w * old_zoom) / 2.0 + self.pan_offset.x;
|
||
let old_origin_y = (bounds.height - engine_h * old_zoom) / 2.0 + self.pan_offset.y;
|
||
let canvas_x = (cursor.x - old_origin_x) / old_zoom;
|
||
let canvas_y = (cursor.y - old_origin_y) / old_zoom;
|
||
let display_w = engine_w * new_zoom;
|
||
let display_h = engine_h * new_zoom;
|
||
let default_x = (bounds.width - display_w) / 2.0;
|
||
let default_y = (bounds.height - display_h) / 2.0;
|
||
let min_vis_w = display_w * 0.25;
|
||
let min_vis_h = display_h * 0.25;
|
||
let origin_x = (cursor.x - canvas_x * new_zoom).clamp(
|
||
-(display_w - min_vis_w).max(0.0),
|
||
(bounds.width - min_vis_w).max(0.0),
|
||
);
|
||
let origin_y = (cursor.y - canvas_y * new_zoom).clamp(
|
||
-(display_h - min_vis_h).max(0.0),
|
||
(bounds.height - min_vis_h).max(0.0),
|
||
);
|
||
(
|
||
new_zoom,
|
||
Vector::new(origin_x - default_x, origin_y - default_y),
|
||
)
|
||
}
|
||
|
||
/// Test which transform handle is at the given viewport position.
|
||
pub fn hit_test_handle(&self, viewport_x: f32, viewport_y: f32) -> TransformHandle {
|
||
let Some(ref tr) = self.selection_transform else {
|
||
return TransformHandle::None;
|
||
};
|
||
if tr.is_empty() {
|
||
return TransformHandle::None;
|
||
}
|
||
|
||
let (origin_x, origin_y, _, _) = self.canvas_origin(iced::Size::new(0.0, 0.0));
|
||
|
||
// Convert viewport to canvas coordinates
|
||
let canvas_x = (viewport_x - origin_x) / self.zoom;
|
||
let canvas_y = (viewport_y - origin_y) / self.zoom;
|
||
|
||
let handle_size = TransformHandle::HANDLE_SIZE / self.zoom;
|
||
let half = handle_size / 2.0;
|
||
|
||
let x = tr.pos.x;
|
||
let y = tr.pos.y;
|
||
let w = tr.size.x;
|
||
let h = tr.size.y;
|
||
|
||
// Check rotation handle first (it's outside the box)
|
||
let rotate_y = y - TransformHandle::ROTATE_OFFSET / self.zoom;
|
||
let rotate_x = x + w / 2.0;
|
||
let rotate_radius = 4.0 / self.zoom;
|
||
let dx = canvas_x - rotate_x;
|
||
let dy = canvas_y - rotate_y;
|
||
if dx * dx + dy * dy <= rotate_radius * rotate_radius {
|
||
return TransformHandle::Rotate;
|
||
}
|
||
|
||
// Check corner and edge handles
|
||
let handles = [
|
||
(TransformHandle::TopLeft, x, y),
|
||
(TransformHandle::TopRight, x + w, y),
|
||
(TransformHandle::BottomLeft, x, y + h),
|
||
(TransformHandle::BottomRight, x + w, y + h),
|
||
(TransformHandle::Top, x + w / 2.0, y),
|
||
(TransformHandle::Bottom, x + w / 2.0, y + h),
|
||
(TransformHandle::Left, x, y + h / 2.0),
|
||
(TransformHandle::Right, x + w, y + h / 2.0),
|
||
];
|
||
|
||
for (handle, hx, hy) in &handles {
|
||
if (canvas_x - hx).abs() <= half && (canvas_y - hy).abs() <= half {
|
||
return *handle;
|
||
}
|
||
}
|
||
|
||
// Check if inside the selection box (for move)
|
||
if canvas_x >= x && canvas_x <= x + w && canvas_y >= y && canvas_y <= y + h {
|
||
return TransformHandle::Move;
|
||
}
|
||
|
||
TransformHandle::None
|
||
}
|
||
|
||
/// Draw transform handles for selection.
|
||
fn draw_transform_handles(
|
||
&self,
|
||
frame: &mut Frame,
|
||
tr: &SelectionTransform,
|
||
origin_x: f32,
|
||
origin_y: f32,
|
||
) {
|
||
let handle_size = TransformHandle::HANDLE_SIZE / self.zoom.max(1.0);
|
||
let half = handle_size / 2.0;
|
||
|
||
// Calculate handle positions in viewport coordinates
|
||
let x = origin_x + tr.pos.x * self.zoom;
|
||
let y = origin_y + tr.pos.y * self.zoom;
|
||
let w = tr.size.x * self.zoom;
|
||
let h = tr.size.y * self.zoom;
|
||
|
||
let handles = [
|
||
(TransformHandle::TopLeft, x, y),
|
||
(TransformHandle::TopRight, x + w, y),
|
||
(TransformHandle::BottomLeft, x, y + h),
|
||
(TransformHandle::BottomRight, x + w, y + h),
|
||
(TransformHandle::Top, x + w / 2.0, y),
|
||
(TransformHandle::Bottom, x + w / 2.0, y + h),
|
||
(TransformHandle::Left, x, y + h / 2.0),
|
||
(TransformHandle::Right, x + w, y + h / 2.0),
|
||
];
|
||
|
||
let handle_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
|
||
let handle_stroke_color = iced::Color::from_rgb(0.0, 0.0, 0.0);
|
||
|
||
for (_handle, hx, hy) in &handles {
|
||
let rect = Path::rectangle(
|
||
Point::new(hx - half, hy - half),
|
||
Size::new(handle_size, handle_size),
|
||
);
|
||
// White fill
|
||
frame.fill(&rect, handle_color);
|
||
// Black stroke
|
||
frame.stroke(
|
||
&rect,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(handle_stroke_color),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
|
||
// Draw rotation handle (circle above top center)
|
||
let rotate_y = y - TransformHandle::ROTATE_OFFSET / self.zoom.max(1.0);
|
||
let rotate_x = x + w / 2.0;
|
||
let rotate_radius = 4.0 / self.zoom.max(1.0);
|
||
let rotate_circle = Path::circle(Point::new(rotate_x, rotate_y), rotate_radius);
|
||
frame.fill(&rotate_circle, handle_color);
|
||
frame.stroke(
|
||
&rotate_circle,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(handle_stroke_color),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
// Draw line from top center to rotation handle
|
||
let line = Path::line(Point::new(x + w / 2.0, y), Point::new(rotate_x, rotate_y));
|
||
frame.stroke(
|
||
&line,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(handle_stroke_color),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Draw crop overlay with handles and rule-of-thirds.
|
||
fn draw_crop_overlay(&self, frame: &mut Frame, crop: &CropState, origin_x: f32, origin_y: f32) {
|
||
if let Some((x, y, w, h)) = crop.rect {
|
||
if w < 1.0 || h < 1.0 {
|
||
return;
|
||
}
|
||
|
||
let cx = origin_x + x * self.zoom;
|
||
let cy = origin_y + y * self.zoom;
|
||
let cw = w * self.zoom;
|
||
let ch = h * self.zoom;
|
||
|
||
// Darken the canvas outside the crop while preserving the chosen region.
|
||
let canvas_w = self.engine_w as f32 * self.zoom;
|
||
let canvas_h = self.engine_h as f32 * self.zoom;
|
||
let shade = iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5);
|
||
for (x, y, w, h) in [
|
||
(origin_x, origin_y, canvas_w, (cy - origin_y).max(0.0)),
|
||
(
|
||
origin_x,
|
||
cy + ch,
|
||
canvas_w,
|
||
(origin_y + canvas_h - cy - ch).max(0.0),
|
||
),
|
||
(origin_x, cy, (cx - origin_x).max(0.0), ch),
|
||
(cx + cw, cy, (origin_x + canvas_w - cx - cw).max(0.0), ch),
|
||
] {
|
||
if w > 0.0 && h > 0.0 {
|
||
frame.fill(&Path::rectangle(Point::new(x, y), Size::new(w, h)), shade);
|
||
}
|
||
}
|
||
|
||
// Draw crop border
|
||
let border = Path::rectangle(Point::new(cx, cy), Size::new(cw, ch));
|
||
frame.stroke(
|
||
&border,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
|
||
width: 2.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
// Draw rule-of-thirds lines
|
||
let third_w = cw / 3.0;
|
||
let third_h = ch / 3.0;
|
||
|
||
for i in 1..3 {
|
||
// Vertical lines
|
||
let vx = cx + third_w * i as f32;
|
||
let vline = Path::line(Point::new(vx, cy), Point::new(vx, cy + ch));
|
||
frame.stroke(
|
||
&vline,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(
|
||
1.0, 1.0, 1.0, 0.5,
|
||
)),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
// Horizontal lines
|
||
let hy = cy + third_h * i as f32;
|
||
let hline = Path::line(Point::new(cx, hy), Point::new(cx + cw, hy));
|
||
frame.stroke(
|
||
&hline,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(
|
||
1.0, 1.0, 1.0, 0.5,
|
||
)),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
|
||
// Draw handles
|
||
let handle_size = 8.0 / self.zoom.max(1.0);
|
||
let half = handle_size / 2.0;
|
||
let handle_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
|
||
let handle_stroke = iced::Color::from_rgb(0.0, 0.0, 0.0);
|
||
|
||
let handles = [
|
||
(cx, cy), // TopLeft
|
||
(cx + cw, cy), // TopRight
|
||
(cx, cy + ch), // BottomLeft
|
||
(cx + cw, cy + ch), // BottomRight
|
||
(cx + cw / 2.0, cy), // Top
|
||
(cx + cw / 2.0, cy + ch), // Bottom
|
||
(cx, cy + ch / 2.0), // Left
|
||
(cx + cw, cy + ch / 2.0), // Right
|
||
];
|
||
|
||
for (hx, hy) in &handles {
|
||
let rect = Path::rectangle(
|
||
Point::new(hx - half, hy - half),
|
||
Size::new(handle_size, handle_size),
|
||
);
|
||
frame.fill(&rect, handle_color);
|
||
frame.stroke(
|
||
&rect,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(handle_stroke),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Draw transform handles for selected vector shapes.
|
||
///
|
||
/// Renders a blue selection outline, 8 white resize handles at corners/edges,
|
||
/// and a rotation handle (line + blue circle) above the top-center. All
|
||
/// geometry is rotated by `angle` around the bounding-box center.
|
||
fn draw_vector_edit_handles(
|
||
&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,
|
||
) {
|
||
let cx = (x1 + x2) / 2.0;
|
||
let cy = (y1 + y2) / 2.0;
|
||
let w = (x2 - x1).abs();
|
||
let h = (y2 - y1).abs();
|
||
if w < 0.5 || h < 0.5 {
|
||
return;
|
||
}
|
||
|
||
let cos_a = angle.cos();
|
||
let sin_a = angle.sin();
|
||
|
||
// Helper: rotate a canvas-space point around (cx, cy) by angle.
|
||
let rotate = |px: f32, py: f32| -> (f32, f32) {
|
||
let dx = px - cx;
|
||
let dy = py - cy;
|
||
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
|
||
};
|
||
|
||
let line_width = 1.5;
|
||
|
||
// ── Blue selection outline ─────────────────────────────────────────
|
||
let outline_color = iced::Color::from_rgb(0.2, 0.5, 1.0);
|
||
let corners: [(f32, f32); 4] = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)];
|
||
let rotated_corners: Vec<(f32, f32)> =
|
||
corners.iter().map(|&(px, py)| rotate(px, py)).collect();
|
||
let outline = Path::new(|b| {
|
||
for (i, &(px, py)) in rotated_corners.iter().enumerate() {
|
||
let sx = origin_x + px * self.zoom;
|
||
let sy = origin_y + py * self.zoom;
|
||
if i == 0 {
|
||
b.move_to(Point::new(sx, sy));
|
||
} else {
|
||
b.line_to(Point::new(sx, sy));
|
||
}
|
||
}
|
||
b.close();
|
||
});
|
||
frame.stroke(
|
||
&outline,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(outline_color),
|
||
width: line_width,
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
// ── 8 resize handles (white squares with black stroke) ─────────────
|
||
let handle_size = 8.0;
|
||
let half = handle_size / 2.0;
|
||
let white = iced::Color::from_rgb(1.0, 1.0, 1.0);
|
||
let black = iced::Color::from_rgb(0.0, 0.0, 0.0);
|
||
|
||
let handle_points: [(hcie_engine_api::VectorEditHandle, f32, f32); 8] = [
|
||
(hcie_engine_api::VectorEditHandle::TopLeft, x1, y1),
|
||
(hcie_engine_api::VectorEditHandle::TopRight, x2, y1),
|
||
(hcie_engine_api::VectorEditHandle::BottomLeft, x1, y2),
|
||
(hcie_engine_api::VectorEditHandle::BottomRight, x2, y2),
|
||
(hcie_engine_api::VectorEditHandle::Top, cx, y1),
|
||
(hcie_engine_api::VectorEditHandle::Bottom, cx, y2),
|
||
(hcie_engine_api::VectorEditHandle::Left, x1, cy),
|
||
(hcie_engine_api::VectorEditHandle::Right, x2, cy),
|
||
];
|
||
|
||
for &(handle, px, py) in &handle_points {
|
||
let (rpx, rpy) = rotate(px, py);
|
||
let sx = origin_x + rpx * self.zoom;
|
||
let sy = origin_y + rpy * self.zoom;
|
||
let rect = Path::rectangle(
|
||
Point::new(sx - half, sy - half),
|
||
Size::new(handle_size, handle_size),
|
||
);
|
||
let highlighted = handle == hovered || handle == self.vector_edit_handle;
|
||
frame.fill(&rect, if highlighted { outline_color } else { white });
|
||
frame.stroke(
|
||
&rect,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(black),
|
||
width: line_width,
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
|
||
// ── Rotation handle (line from top-center upward + blue circle) ────
|
||
let rotate_offset = crate::vector_edit::ROTATE_OFFSET_PX / self.zoom.max(0.000_1);
|
||
let (rot_line_start_x, rot_line_start_y) = rotate(cx, y1);
|
||
let (rot_circ_x, rot_circ_y) = rotate(cx, y1 - rotate_offset);
|
||
let rot_circ_radius = 4.0;
|
||
|
||
// Line from top-center to rotation circle
|
||
let rot_line = Path::line(
|
||
Point::new(
|
||
origin_x + rot_line_start_x * self.zoom,
|
||
origin_y + rot_line_start_y * self.zoom,
|
||
),
|
||
Point::new(
|
||
origin_x + rot_circ_x * self.zoom,
|
||
origin_y + rot_circ_y * self.zoom,
|
||
),
|
||
);
|
||
frame.stroke(
|
||
&rot_line,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(black),
|
||
width: line_width,
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
// Blue rotation circle
|
||
let rot_circle = Path::circle(
|
||
Point::new(
|
||
origin_x + rot_circ_x * self.zoom,
|
||
origin_y + rot_circ_y * self.zoom,
|
||
),
|
||
rot_circ_radius,
|
||
);
|
||
let blue = iced::Color::from_rgb(0.2, 0.5, 1.0);
|
||
let rotate_highlighted = hovered == hcie_engine_api::VectorEditHandle::Rotate
|
||
|| self.vector_edit_handle == hcie_engine_api::VectorEditHandle::Rotate;
|
||
frame.fill(&rot_circle, if rotate_highlighted { white } else { blue });
|
||
frame.stroke(
|
||
&rot_circle,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(black),
|
||
width: line_width,
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
impl canvas::Program<Message> for OverlayProgram {
|
||
type State = OverlayState;
|
||
|
||
fn draw(
|
||
&self,
|
||
state: &Self::State,
|
||
renderer: &iced::Renderer,
|
||
_theme: &iced::Theme,
|
||
bounds: Rectangle,
|
||
_cursor: Cursor,
|
||
) -> Vec<canvas::Geometry> {
|
||
let (origin_x, origin_y, _display_w, _display_h) = self.canvas_origin(bounds.size());
|
||
let mut geometries = Vec::new();
|
||
|
||
// ── Selection rectangle or transform handles ───────────────────────
|
||
let has_overlays = self.selection_rect.is_some()
|
||
|| self.selection_bounds.is_some()
|
||
|| self.vector_draw.is_some()
|
||
|| self.selected_vector_bounds.is_some()
|
||
|| self.selection_transform.is_some()
|
||
|| self.crop_state.as_ref().map_or(false, |c| c.active)
|
||
|| !self.lasso_points.is_empty()
|
||
|| !self.polygon_points.is_empty();
|
||
|
||
if has_overlays {
|
||
let mut frame = Frame::new(renderer, bounds.size());
|
||
|
||
// 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;
|
||
let sel_y = origin_y + y0.min(y1) * self.zoom;
|
||
let sel_w = (x1 - x0).abs() * self.zoom;
|
||
let sel_h = (y1 - y0).abs() * self.zoom;
|
||
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.6, 1.0,
|
||
)),
|
||
width: 2.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// Selection overlay (fill + marching ants) is now rendered by the GPU shader.
|
||
// Fill exact mask runs are handled in the fragment shader via selection mask texture.
|
||
// The shader computes:
|
||
// - Purple/red fill for selected pixels
|
||
// - Animated black+white dashes along the selection border
|
||
|
||
// Draw lasso freehand path while dragging
|
||
if self.lasso_points.len() > 1 {
|
||
let lasso_path = Path::new(|b| {
|
||
let first = &self.lasso_points[0];
|
||
let start = Point::new(
|
||
origin_x + first.0 as f32 * self.zoom,
|
||
origin_y + first.1 as f32 * self.zoom,
|
||
);
|
||
b.move_to(start);
|
||
for pt in &self.lasso_points[1..] {
|
||
let p = Point::new(
|
||
origin_x + pt.0 as f32 * self.zoom,
|
||
origin_y + pt.1 as f32 * self.zoom,
|
||
);
|
||
b.line_to(p);
|
||
}
|
||
b.close();
|
||
});
|
||
frame.stroke(
|
||
&lasso_path,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.6, 1.0)),
|
||
width: 1.5 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
|
||
// Draw polygon selection points while placing vertices
|
||
if self.polygon_points.len() > 1 {
|
||
let poly_path = Path::new(|b| {
|
||
let first = &self.polygon_points[0];
|
||
let start = Point::new(
|
||
origin_x + first.0 as f32 * self.zoom,
|
||
origin_y + first.1 as f32 * self.zoom,
|
||
);
|
||
b.move_to(start);
|
||
for pt in &self.polygon_points[1..] {
|
||
let p = Point::new(
|
||
origin_x + pt.0 as f32 * self.zoom,
|
||
origin_y + pt.1 as f32 * self.zoom,
|
||
);
|
||
b.line_to(p);
|
||
}
|
||
});
|
||
frame.stroke(
|
||
&poly_path,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.6, 1.0)),
|
||
width: 1.5 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
// Draw vertex dots
|
||
for pt in &self.polygon_points {
|
||
let p = Point::new(
|
||
origin_x + pt.0 as f32 * self.zoom,
|
||
origin_y + pt.1 as f32 * self.zoom,
|
||
);
|
||
frame.fill(
|
||
&Path::circle(p, 3.0 / self.zoom.max(1.0)),
|
||
iced::Color::from_rgb(0.0, 0.6, 1.0),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Draw vector shape preview — shape geometry matches the active
|
||
// tool so the user sees what will be drawn (egui draws the actual
|
||
// 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)),
|
||
width: 2.0 / self.zoom.max(1.0),
|
||
line_dash: canvas::LineDash {
|
||
segments: &[5.0, 5.0],
|
||
offset: 0,
|
||
},
|
||
..Default::default()
|
||
};
|
||
|
||
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;
|
||
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();
|
||
let py = cy + ry * t.sin();
|
||
if i == 0 {
|
||
b.move_to(Point::new(px, py));
|
||
} else {
|
||
b.line_to(Point::new(px, py));
|
||
}
|
||
}
|
||
b.close();
|
||
});
|
||
frame.stroke(&ellipse_path, stroke_style);
|
||
}
|
||
hcie_engine_api::Tool::VectorLine => {
|
||
// Straight 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 line_path = Path::line(p0, p1);
|
||
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;
|
||
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 px = cx + r * t.cos();
|
||
let py = cy + r * t.sin();
|
||
if i == 0 {
|
||
b.move_to(Point::new(px, py));
|
||
} else {
|
||
b.line_to(Point::new(px, py));
|
||
}
|
||
}
|
||
b.close();
|
||
});
|
||
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;
|
||
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 px = cx + r * t.cos();
|
||
let py = cy + r * t.sin();
|
||
if i == 0 {
|
||
b.move_to(Point::new(px, py));
|
||
} else {
|
||
b.line_to(Point::new(px, py));
|
||
}
|
||
}
|
||
b.close();
|
||
});
|
||
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.
|
||
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);
|
||
}
|
||
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;
|
||
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.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;
|
||
let ellipse_rx = hw;
|
||
let ellipse_ry = hw * 0.3;
|
||
let segments = 36;
|
||
// Top ellipse
|
||
let top_cy = top + 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 py = top_cy + ellipse_ry * t.sin();
|
||
if i == 0 {
|
||
b.move_to(Point::new(px, py));
|
||
} else {
|
||
b.line_to(Point::new(px, py));
|
||
}
|
||
}
|
||
b.close();
|
||
});
|
||
frame.stroke(&top_ellipse, stroke_style);
|
||
// Bottom ellipse
|
||
let bot_cy = bottom - 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 py = bot_cy + ellipse_ry * t.sin();
|
||
if i == 0 {
|
||
b.move_to(Point::new(px, py));
|
||
} else {
|
||
b.line_to(Point::new(px, py));
|
||
}
|
||
}
|
||
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));
|
||
frame.stroke(&left_line, stroke_style);
|
||
// Right vertical line
|
||
let right_line =
|
||
Path::line(Point::new(right, top_cy), Point::new(right, 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;
|
||
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),
|
||
);
|
||
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;
|
||
let segments = 48;
|
||
let bubble_path = Path::new(|b| {
|
||
for i in 0..=segments {
|
||
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
|
||
let px = cx + rx * t.cos();
|
||
let py = cy + ry * t.sin();
|
||
if i == 0 {
|
||
b.move_to(Point::new(px, py));
|
||
} else {
|
||
b.line_to(Point::new(px, py));
|
||
}
|
||
}
|
||
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;
|
||
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.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;
|
||
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());
|
||
if i == 0 {
|
||
b.move_to(p0);
|
||
} else {
|
||
b.line_to(p0);
|
||
}
|
||
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)
|
||
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();
|
||
if i == 0 {
|
||
b.move_to(Point::new(px, py));
|
||
} else {
|
||
b.line_to(Point::new(px, py));
|
||
}
|
||
}
|
||
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;
|
||
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.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;
|
||
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));
|
||
b.close();
|
||
});
|
||
frame.stroke(&arrow4_path, stroke_style);
|
||
}
|
||
_ => {
|
||
// Rectangular shapes (Rect, Arrow, Rhombus, etc.) fall
|
||
// back to a bounding-box outline for the preview.
|
||
// For VectorRect, show rounded corners when rect_radius > 0.
|
||
let v_x = origin_x + x0.min(x1) * self.zoom;
|
||
let v_y = origin_y + y0.min(y1) * self.zoom;
|
||
let v_w = (x1 - x0).abs() * self.zoom;
|
||
let v_h = (y1 - y0).abs() * self.zoom;
|
||
let v_path = if self.active_tool == hcie_engine_api::Tool::VectorRect
|
||
&& self.rect_radius > 0.0
|
||
{
|
||
let max_r = (v_w.min(v_h) / 2.0).min(self.rect_radius * self.zoom);
|
||
let k = max_r * 0.5523; // cubic approximation of quarter circle
|
||
Path::new(|b| {
|
||
b.move_to(Point::new(v_x + max_r, v_y));
|
||
b.line_to(Point::new(v_x + v_w - max_r, v_y));
|
||
b.bezier_curve_to(
|
||
Point::new(v_x + v_w - max_r + k, v_y),
|
||
Point::new(v_x + v_w, v_y + max_r - k),
|
||
Point::new(v_x + v_w, v_y + max_r),
|
||
);
|
||
b.line_to(Point::new(v_x + v_w, v_y + v_h - max_r));
|
||
b.bezier_curve_to(
|
||
Point::new(v_x + v_w, v_y + v_h - max_r + k),
|
||
Point::new(v_x + v_w - max_r + k, v_y + v_h),
|
||
Point::new(v_x + v_w - max_r, v_y + v_h),
|
||
);
|
||
b.line_to(Point::new(v_x + max_r, v_y + v_h));
|
||
b.bezier_curve_to(
|
||
Point::new(v_x + max_r - k, v_y + v_h),
|
||
Point::new(v_x, v_y + v_h - max_r + k),
|
||
Point::new(v_x, v_y + v_h - max_r),
|
||
);
|
||
b.line_to(Point::new(v_x, v_y + max_r));
|
||
b.bezier_curve_to(
|
||
Point::new(v_x, v_y + max_r - k),
|
||
Point::new(v_x + max_r - k, v_y),
|
||
Point::new(v_x + max_r, v_y),
|
||
);
|
||
b.close();
|
||
})
|
||
} else {
|
||
Path::rectangle(Point::new(v_x, v_y), Size::new(v_w, v_h))
|
||
};
|
||
frame.stroke(&v_path, stroke_style);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw vector edit handles for selected shape (blue outline, resize + rotation)
|
||
if let Some((x1, y1, x2, y2)) = self.selected_vector_bounds {
|
||
self.draw_vector_edit_handles(
|
||
&mut frame,
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
self.selected_vector_angle,
|
||
origin_x,
|
||
origin_y,
|
||
state.hovered_vector_handle,
|
||
);
|
||
}
|
||
|
||
// Draw gradient endpoint preview line.
|
||
if let Some(((gx0, gy0), (gx1, gy1))) = self.gradient_drag {
|
||
let p0 = Point::new(origin_x + gx0 * self.zoom, origin_y + gy0 * self.zoom);
|
||
let p1 = Point::new(origin_x + gx1 * self.zoom, origin_y + gy1 * self.zoom);
|
||
let g_path = Path::line(p0, p1);
|
||
frame.stroke(
|
||
&g_path,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.2, 0.8, 1.0)),
|
||
width: 2.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
// Endpoint markers.
|
||
frame.fill(&Path::circle(p0, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0));
|
||
frame.fill(&Path::circle(p1, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0));
|
||
}
|
||
|
||
// Draw transform handles (when in transform mode)
|
||
if let Some(ref tr) = self.selection_transform {
|
||
if !tr.is_empty() {
|
||
// Draw dotted bounding box
|
||
let x = origin_x + tr.pos.x * self.zoom;
|
||
let y = origin_y + tr.pos.y * self.zoom;
|
||
let w = tr.size.x * self.zoom;
|
||
let h = tr.size.y * self.zoom;
|
||
let bbox = Path::rectangle(Point::new(x, y), Size::new(w, h));
|
||
frame.stroke(
|
||
&bbox,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(
|
||
0.5, 0.5, 1.0,
|
||
)),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
line_dash: canvas::LineDash {
|
||
segments: &[4.0, 4.0],
|
||
offset: 0,
|
||
},
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
// Draw handles
|
||
self.draw_transform_handles(&mut frame, tr, origin_x, origin_y);
|
||
}
|
||
}
|
||
|
||
// Draw crop overlay
|
||
if let Some(ref crop) = self.crop_state {
|
||
if crop.active {
|
||
self.draw_crop_overlay(&mut frame, crop, origin_x, origin_y);
|
||
}
|
||
}
|
||
|
||
geometries.push(frame.into_geometry());
|
||
}
|
||
|
||
// ── Crosshair cursor ─────────────────────────────────────────────
|
||
if state.is_hovered {
|
||
if let Some(cursor) = state.cursor_pos {
|
||
let mut crosshair_frame = Frame::new(renderer, bounds.size());
|
||
let crosshair_size = 10.0;
|
||
let crosshair_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
|
||
if matches!(
|
||
self.active_tool,
|
||
hcie_engine_api::Tool::Pen
|
||
| hcie_engine_api::Tool::Brush
|
||
| hcie_engine_api::Tool::Eraser
|
||
| hcie_engine_api::Tool::Spray
|
||
) {
|
||
let footprint =
|
||
Path::circle(cursor, (self.brush_size * self.zoom * 0.5).max(1.0));
|
||
crosshair_frame.stroke(
|
||
&footprint,
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||
width: 1.0,
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
crosshair_frame.stroke(
|
||
&Path::line(
|
||
Point::new(cursor.x - crosshair_size, cursor.y),
|
||
Point::new(cursor.x + crosshair_size, cursor.y),
|
||
),
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
crosshair_frame.stroke(
|
||
&Path::line(
|
||
Point::new(cursor.x, cursor.y - crosshair_size),
|
||
Point::new(cursor.x, cursor.y + crosshair_size),
|
||
),
|
||
Stroke {
|
||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||
width: 1.0 / self.zoom.max(1.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
geometries.push(crosshair_frame.into_geometry());
|
||
}
|
||
}
|
||
|
||
// ── Pressure indicator HUD ──────────────────────────────────────
|
||
if self.pressure < 1.0 {
|
||
let mut pressure_frame = Frame::new(renderer, bounds.size());
|
||
crate::panels::pressure_indicator::draw(&mut pressure_frame, bounds, self.pressure);
|
||
geometries.push(pressure_frame.into_geometry());
|
||
}
|
||
|
||
geometries
|
||
}
|
||
|
||
fn update(
|
||
&self,
|
||
state: &mut Self::State,
|
||
event: canvas::Event,
|
||
bounds: Rectangle,
|
||
cursor: Cursor,
|
||
) -> (canvas::event::Status, Option<Message>) {
|
||
// Overlay only tracks cursor position for crosshair drawing.
|
||
// All actual input handling (drawing, panning, zooming) is done
|
||
// by the shader widget underneath.
|
||
match event {
|
||
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
|
||
if cursor.is_over(bounds) =>
|
||
{
|
||
let scroll_y = match delta {
|
||
mouse::ScrollDelta::Lines { y, .. } => y,
|
||
mouse::ScrollDelta::Pixels { y, .. } => y / 50.0,
|
||
};
|
||
if scroll_y != 0.0 {
|
||
let local = cursor
|
||
.position_in(bounds)
|
||
.unwrap_or(Point::new(bounds.width / 2.0, bounds.height / 2.0));
|
||
let factor = if scroll_y > 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||
let (zoom, pan_offset) = self.wheel_zoom(bounds, local, factor);
|
||
return (
|
||
canvas::event::Status::Captured,
|
||
Some(Message::CanvasPanZoom { zoom, pan_offset }),
|
||
);
|
||
}
|
||
}
|
||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||
let local_pos = Point::new(position.x - bounds.x, position.y - bounds.y);
|
||
state.cursor_pos = Some(local_pos);
|
||
state.is_hovered = bounds.contains(position);
|
||
|
||
// Update hovered handle
|
||
if self.selection_transform.is_some() {
|
||
state.hovered_handle = self.hit_test_handle(position.x, position.y);
|
||
} else {
|
||
state.hovered_handle = TransformHandle::None;
|
||
}
|
||
state.hovered_vector_handle =
|
||
if let Some(vector_bounds) = self.selected_vector_bounds {
|
||
let (origin_x, origin_y, _, _) = self.canvas_origin(bounds.size());
|
||
let point = (
|
||
(local_pos.x - origin_x) / self.zoom.max(0.000_1),
|
||
(local_pos.y - origin_y) / self.zoom.max(0.000_1),
|
||
);
|
||
crate::vector_edit::hit_test_handle(
|
||
vector_bounds,
|
||
self.selected_vector_angle,
|
||
point,
|
||
self.zoom,
|
||
)
|
||
} else {
|
||
hcie_engine_api::VectorEditHandle::None
|
||
};
|
||
}
|
||
_ => {
|
||
if state.is_hovered && !cursor.is_over(bounds) {
|
||
state.is_hovered = false;
|
||
state.cursor_pos = None;
|
||
state.hovered_handle = TransformHandle::None;
|
||
state.hovered_vector_handle = hcie_engine_api::VectorEditHandle::None;
|
||
}
|
||
}
|
||
}
|
||
// Always pass events through to the shader widget below
|
||
(canvas::event::Status::Ignored, None)
|
||
}
|
||
|
||
/// Selects a cursor matching the hovered vector transform handle.
|
||
///
|
||
/// Arguments: Current overlay state, widget bounds, and pointer cursor. Returns: A resize,
|
||
/// grab, or crosshair interaction. Side Effects: None.
|
||
fn mouse_interaction(
|
||
&self,
|
||
state: &Self::State,
|
||
bounds: Rectangle,
|
||
cursor: Cursor,
|
||
) -> mouse::Interaction {
|
||
use hcie_engine_api::VectorEditHandle as H;
|
||
if !cursor.is_over(bounds) {
|
||
return mouse::Interaction::default();
|
||
}
|
||
match state.hovered_vector_handle {
|
||
H::Move => mouse::Interaction::Grab,
|
||
H::Top | H::Bottom => mouse::Interaction::ResizingVertically,
|
||
H::Left | H::Right => mouse::Interaction::ResizingHorizontally,
|
||
H::TopLeft | H::BottomRight => mouse::Interaction::ResizingDiagonallyDown,
|
||
H::TopRight | H::BottomLeft => mouse::Interaction::ResizingDiagonallyUp,
|
||
H::Rotate => mouse::Interaction::Pointer,
|
||
H::None => mouse::Interaction::Crosshair,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Public view function ────────────────────────────────────────────────────
|
||
|
||
/// Build the canvas viewport element using the GPU shader pipeline.
|
||
///
|
||
/// ## Architecture
|
||
/// Returns a `Stack` with two layers:
|
||
/// 1. **Bottom**: `iced::widget::Shader` — renders checkerboard + composite texture
|
||
/// via a custom wgpu pipeline. Uses `queue.write_texture()` for partial updates.
|
||
/// 2. **Top**: `iced::widget::Canvas` — renders selection rects, vector previews,
|
||
/// crosshair cursor, and pressure indicator HUD (transparent overlay).
|
||
///
|
||
/// ## 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)
|
||
/// * `pressure` — current pen pressure in [0.0, 1.0] for HUD display
|
||
/// * `star_points` — number of points for star shape preview
|
||
/// * `polygon_sides` — number of sides for polygon shape preview
|
||
/// * `rect_radius` — corner radius for rectangle shape preview
|
||
pub fn view<'a>(
|
||
doc: &'a crate::app::IcedDocument,
|
||
tool_state: &'a crate::app::ToolState,
|
||
marching_ants_offset: f32,
|
||
pressure: f32,
|
||
star_points: u32,
|
||
polygon_sides: u32,
|
||
rect_radius: f32,
|
||
) -> Element<'a, Message> {
|
||
let engine_w = doc.engine.canvas_width();
|
||
let engine_h = doc.engine.canvas_height();
|
||
let zoom = doc.zoom;
|
||
let pan_offset = doc.pan_offset;
|
||
|
||
// ── Shader canvas (main rendering) ───────────────────────────────────
|
||
let shader_program = CanvasShaderProgram {
|
||
engine_w,
|
||
engine_h,
|
||
zoom,
|
||
pan_offset,
|
||
composite_pixels: doc.composite_pixels.clone(),
|
||
dirty_region: doc.dirty_region.take(),
|
||
full_upload: doc.full_upload.replace(false),
|
||
space_pan: tool_state.space_pan,
|
||
selection_mask: doc.selection_mask.clone(),
|
||
selection_dirty: doc.selection_mask_dirty.get(),
|
||
anim_time: marching_ants_offset * 2.0, // Convert 0..1 to seconds (2s cycle)
|
||
quick_mask: doc.quick_mask,
|
||
};
|
||
doc.selection_mask_dirty.set(false);
|
||
|
||
let shader_canvas = Shader::new(shader_program)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill);
|
||
|
||
// ── 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)
|
||
} else {
|
||
(None, 0.0)
|
||
}
|
||
} else {
|
||
(None, 0.0)
|
||
}
|
||
} else {
|
||
(None, 0.0)
|
||
};
|
||
|
||
let overlay_program = OverlayProgram {
|
||
engine_w,
|
||
engine_h,
|
||
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,
|
||
pressure,
|
||
active_tool: tool_state.active_tool,
|
||
star_points,
|
||
polygon_sides,
|
||
rect_radius,
|
||
gradient_drag: doc.gradient_drag,
|
||
lasso_points: doc.lasso_points.clone(),
|
||
polygon_points: doc.polygon_points.clone(),
|
||
marching_ants_edges: doc.marching_ants_edges.clone(),
|
||
selection_fill_spans: doc.selection_fill_spans.clone(),
|
||
quick_mask: doc.quick_mask,
|
||
brush_size: tool_state.brush_size,
|
||
selected_vector_bounds: sel_vec_bounds,
|
||
selected_vector_angle: sel_vec_angle,
|
||
vector_edit_handle: doc.vector_edit_handle,
|
||
};
|
||
|
||
let overlay_canvas = canvas(overlay_program)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill);
|
||
|
||
// ── Stack: shader (bottom) + overlay (top) + text editor (when drafting) ──
|
||
let mut stacked = Stack::new()
|
||
.push(shader_canvas)
|
||
.push(overlay_canvas)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill);
|
||
|
||
// On-canvas text editor: a positioned text_input + Commit/Cancel buttons
|
||
// placed at the draft location so the user can type inline (egui:
|
||
// text_overlay.rs:32-192). iced's Stack aligns children to the top-left, so
|
||
// we offset with left/top padding computed from the draft's canvas
|
||
// position mapped to pane-space.
|
||
if let Some(draft) = doc.text_draft.as_ref() {
|
||
let (pane_w, pane_h) = doc.pane_size;
|
||
let display_w = engine_w as f32 * zoom;
|
||
let display_h = engine_h as f32 * zoom;
|
||
let origin_x = (pane_w - display_w) / 2.0 + pan_offset.x;
|
||
let origin_y = (pane_h - display_h) / 2.0 + pan_offset.y;
|
||
let text_screen_x = (origin_x + draft.x * zoom).max(4.0);
|
||
let text_screen_y = (origin_y + draft.y * zoom).max(4.0);
|
||
|
||
let editor_bg = |_theme: &iced::Theme| iced::widget::container::Style {
|
||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||
0.1, 0.1, 0.12, 0.92,
|
||
))),
|
||
border: iced::Border::default()
|
||
.color(iced::Color::from_rgb(0.4, 0.5, 1.0))
|
||
.width(1)
|
||
.rounded(3),
|
||
..Default::default()
|
||
};
|
||
let editor = container(
|
||
column![
|
||
iced::widget::text_editor(&draft.editor)
|
||
.placeholder("Type text here...")
|
||
.on_action(Message::TextEditorAction)
|
||
.height(Length::Fixed(96.0)),
|
||
row![
|
||
iced::widget::button(iced::widget::text("Commit").size(10))
|
||
.on_press(Message::TextCommit)
|
||
.padding([2, 8]),
|
||
iced::widget::button(iced::widget::text("Cancel").size(10))
|
||
.on_press(Message::TextCancel)
|
||
.padding([2, 8]),
|
||
]
|
||
.spacing(6),
|
||
]
|
||
.spacing(2),
|
||
)
|
||
.padding(4)
|
||
.style(editor_bg);
|
||
|
||
let positioned_editor = container(editor)
|
||
.width(Length::Shrink)
|
||
.padding(iced::Padding {
|
||
top: text_screen_y,
|
||
bottom: 0.0,
|
||
left: text_screen_x,
|
||
right: 0.0,
|
||
});
|
||
|
||
stacked = stacked.push(positioned_editor);
|
||
}
|
||
|
||
// ── Vector shape Apply / Cancel / Flip overlay ──────────────────────
|
||
// Floating buttons shown below the selected vector shape for quick
|
||
// commit, cancel, or flip operations.
|
||
if let Some((bx1, by1, bx2, by2)) = sel_vec_bounds {
|
||
let (pane_w, pane_h) = doc.pane_size;
|
||
let display_w = engine_w as f32 * zoom;
|
||
let display_h = engine_h as f32 * zoom;
|
||
let origin_x = (pane_w - display_w) / 2.0 + pan_offset.x;
|
||
let origin_y = (pane_h - display_h) / 2.0 + pan_offset.y;
|
||
|
||
let cx = (bx1 + bx2) / 2.0;
|
||
let cy = (by2).max(by1) + 10.0; // below shape
|
||
let controls_width = 286.0;
|
||
let controls_height = 28.0;
|
||
let screen_x = (origin_x + cx * zoom - controls_width * 0.5)
|
||
.clamp(4.0, (pane_w - controls_width - 4.0).max(4.0));
|
||
let screen_y = (origin_y + cy * zoom).clamp(4.0, (pane_h - controls_height - 4.0).max(4.0));
|
||
|
||
let btn_style = |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||
iced::widget::button::Style {
|
||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||
0.15, 0.15, 0.18, 0.9,
|
||
))),
|
||
border: iced::Border::default()
|
||
.color(iced::Color::from_rgba(0.35, 0.35, 0.4, 1.0))
|
||
.width(1)
|
||
.rounded(4),
|
||
text_color: iced::Color::from_rgb(0.9, 0.9, 0.9),
|
||
..Default::default()
|
||
}
|
||
};
|
||
|
||
let apply_btn = iced::widget::button(iced::widget::text("Apply").size(10))
|
||
.on_press(Message::VectorShapeApply)
|
||
.padding([3, 8])
|
||
.style(btn_style);
|
||
|
||
let cancel_btn = iced::widget::button(iced::widget::text("Cancel").size(10))
|
||
.on_press(Message::VectorShapeCancel)
|
||
.padding([3, 8])
|
||
.style(btn_style);
|
||
|
||
let flip_h_btn = iced::widget::button(iced::widget::text("Flip H").size(10))
|
||
.on_press(Message::VectorShapeFlipH)
|
||
.padding([3, 8])
|
||
.style(btn_style);
|
||
|
||
let flip_v_btn = iced::widget::button(iced::widget::text("Flip V").size(10))
|
||
.on_press(Message::VectorShapeFlipV)
|
||
.padding([3, 8])
|
||
.style(btn_style);
|
||
|
||
let delete_btn = iced::widget::button(iced::widget::text("Delete").size(10))
|
||
.on_press(Message::VectorShapeDelete)
|
||
.padding([3, 8])
|
||
.style(btn_style);
|
||
|
||
let overlay_row =
|
||
row![apply_btn, cancel_btn, flip_h_btn, flip_v_btn, delete_btn].spacing(4);
|
||
|
||
let overlay_container =
|
||
container(overlay_row)
|
||
.width(Length::Shrink)
|
||
.padding(iced::Padding {
|
||
top: screen_y,
|
||
bottom: 0.0,
|
||
left: screen_x,
|
||
right: 0.0,
|
||
});
|
||
|
||
stacked = stacked.push(overlay_container);
|
||
}
|
||
|
||
// ── Tool info strip (bottom of canvas area) ─────────────────────────
|
||
let overlay_info = if let Some((x0, y0, x1, y1)) = doc.selection_rect {
|
||
let w = (x1 - x0).abs() as u32;
|
||
let h = (y1 - y0).abs() as u32;
|
||
Some(format!("Selection: {}×{}", w, h))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let vector_info = if let Some(((x0, y0), (x1, y1))) = doc.vector_draw {
|
||
let w = (x1 - x0).abs() as u32;
|
||
let h = (y1 - y0).abs() as u32;
|
||
Some(format!("Shape: {}×{}", w, h))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let mut overlay_lines = vec![];
|
||
if let Some(sel) = overlay_info {
|
||
overlay_lines.push(sel);
|
||
}
|
||
if let Some(vec) = vector_info {
|
||
overlay_lines.push(vec);
|
||
}
|
||
|
||
let overlay_text = if overlay_lines.is_empty() {
|
||
text("").size(10)
|
||
} else {
|
||
text(overlay_lines.join(" | ")).size(10)
|
||
};
|
||
|
||
let tool_info = container(
|
||
row![
|
||
text(format!("{:?}", tool_state.active_tool)).size(11),
|
||
iced::widget::horizontal_rule(1),
|
||
overlay_text,
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Alignment::Center),
|
||
)
|
||
.padding([2, 8]);
|
||
|
||
column![
|
||
container(stacked)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.clip(true),
|
||
tool_info,
|
||
]
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.clip(true)
|
||
.into()
|
||
}
|