Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs
T
phantom b2c88e5471 feat(canvas): enhance composite shader with selection overlay and marching ants
- 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.
2026-07-17 23:15:27 +03:00

1661 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()
}