Files
hcie-rust-v3.05/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs
T
Your Name a2264b130d 4K MULTI LAYEPERFORMANCE OK : SOL 5.6 HIGH feat(perf): add real-time canvas performance measurements
- Introduced a new performance module for the iced canvas to measure input queueing, engine drawing, CPU compositing/staging, GPU upload, and renderer preparation costs.
- Implemented a performance probe in the egui reference renderer for comparative diagnostics.
- Added methods to record durations, byte transfers, and input timestamps, enabling detailed performance analysis.
- Enhanced the CanvasShaderPipeline with a new method to upload full textures without rebuilding GPU resources.
- Updated the main application to conditionally log performance diagnostics based on an environment variable.
- Created a performance plan document outlining steps to measure and optimize drawing performance in the iced application.
2026-07-23 23:57:03 +03:00

2640 lines
137 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 widget — zoom, pan, pointer interaction, and texture display.
use crate::app::{tools::shape_sync, AppDocument, SelectionTransform, ToolState, TransformHandle};
use crate::event_bus::{AppEvent, EventBus};
use eframe::egui;
use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN};
use std::hash::{Hash, Hasher};
pub mod perf;
pub mod render;
pub mod text_overlay;
pub struct CanvasWidget<'a> {
doc: &'a mut AppDocument,
state: &'a mut ToolState,
event_bus: &'a mut EventBus,
transform_texture: &'a mut Option<egui::TextureHandle>,
/// Index of this document in the documents vec. Used to prevent
/// cross-document drawing interference: only the active document
/// accepts stroke/drawing interactions.
doc_index: usize,
/// Index of the currently active document, used together with doc_index
/// to guard drawing operations.
active_doc: usize,
}
impl<'a> CanvasWidget<'a> {
pub fn new(
doc: &'a mut AppDocument,
state: &'a mut ToolState,
event_bus: &'a mut EventBus,
transform_texture: &'a mut Option<egui::TextureHandle>,
doc_index: usize,
active_doc: usize,
) -> Self {
Self {
doc,
state,
event_bus,
transform_texture,
doc_index,
active_doc,
}
}
}
impl<'a> egui::Widget for CanvasWidget<'a> {
/// ### Purpose
/// Renders the central painting canvas, handles pan/zoom operations, tracks pointer/pressure inputs,
/// and invokes engine compositing/rendering functions.
///
/// ### Logic & Workflow
/// 1. Computes the current zoom-adjusted display size and allocates a draggable/clickable painter.
/// 2. Calculates the absolute canvas rectangle offset by the current pan coordinates.
/// 3. Binds scroll-wheel events to update zoom levels, shifting the pan offset to center on the mouse pointer.
/// 4. Maps raw screen cursor coordinates to canvas-space coordinates.
/// 5. Feeds touch/pen pressure levels to the tablet state manager.
/// 6. Tracks drag/press operations to send start_stroke, stroke_to, and end_stroke events to the Engine. Leverages raw pointer state fallbacks to keep drawing active if egui's default drag states are interrupted by focus changes or tab activation.
/// 7. Records specific brush/eraser stroke actions with refined names into the document's history log.
/// 8. Invokes texture rendering for composite buffers and selection/transform overlays.
///
/// ### Arguments
/// * `ui` - The active egui UI layout context.
///
/// ### Returns
/// An egui response containing widget layout and interaction state.
///
/// ### Side Effects / Dependencies
/// - Mutates `AppDocument` (zoom, canvas textures, history description, engine state).
/// - Mutates `ToolState` (cursor position, pan offset, pressure levels, lasso/drawing states).
/// - Dispatches rendering, settings save, and drawing finish events to the `EventBus`.
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
let current_ctx = ui.ctx().clone();
if self.doc.last_ctx.as_ref() != Some(&current_ctx) {
log::info!(
"[CanvasWidget::ui] Context changed for doc {}; clearing cached GPU textures",
self.doc.name
);
self.doc.composite_texture = None;
self.doc.layer_textures.clear();
self.doc.last_ctx = Some(current_ctx);
}
let canvas_w = self.doc.engine.canvas_width() as f32;
let canvas_h = self.doc.engine.canvas_height() as f32;
let zoom = self.doc.zoom;
let display_size = egui::Vec2::new(canvas_w * zoom, canvas_h * zoom);
// Guard: only the active document should accept drawing input.
// Non-active canvases still render their texture but skip pointer interactions.
let is_active_doc = self.doc_index == self.active_doc;
let primary_down = ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary));
let was_drawing = self.state.is_drawing && is_active_doc;
let primary_released = !primary_down && was_drawing;
if !primary_down && is_active_doc {
self.state.is_drawing = false;
self.state.brush_accumulated_dist = 0.0;
}
let (response, painter) =
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
let pan = self.doc.pan_offset;
let canvas_rect = egui::Rect::from_center_size(
response.rect.center() + egui::vec2(pan.x, pan.y),
display_size,
);
// Hover tracking: prefer egui pointer hover, fallback to tablet state.
let hover_pos = ui.input(|i| i.pointer.hover_pos()).or_else(|| {
self.state
.tablet_state
.lock()
.ok()
.and_then(|s| s.current_pos())
});
if let Some(pos) = hover_pos {
if response.hovered() && canvas_rect.contains(pos) {
let rel = pos - canvas_rect.min;
let cx = (rel.x / zoom) as i32;
let cy = (rel.y / zoom) as i32;
if cx >= 0 && cy >= 0 {
self.state.cursor_canvas_pos = Some((cx as u32, cy as u32));
}
}
}
// ── Zoom (scroll wheel) ────────────────────────────────
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
if scroll_delta != 0.0 && response.hovered() {
let ctrl = ui.input(|i| i.modifiers.command);
let factor = if ctrl {
if scroll_delta > 0.0 {
1.1
} else {
1.0 / 1.1
}
} else {
(scroll_delta / 200.0).exp()
};
let old_zoom = self.doc.zoom;
let new_zoom = (old_zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
self.doc.zoom = new_zoom;
if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) {
let actual_factor = new_zoom / old_zoom;
let rel_pos = mouse_pos - response.rect.center();
let scroll_shift = (rel_pos - egui::vec2(pan.x, pan.y)) * (1.0 - actual_factor);
self.doc.pan_offset.x += scroll_shift.x;
self.doc.pan_offset.y += scroll_shift.y;
}
}
// ── Pan (middle mouse drag) ─────────────────────────────
if response.dragged_by(egui::PointerButton::Middle) {
let delta = response.drag_delta();
self.doc.pan_offset.x += delta.x;
self.doc.pan_offset.y += delta.y;
}
// ── Pointer interaction ──────────────────────────────────
// Feed screen size so evdev can map tablet coords to screen pixels.
{
let screen = ui.available_size();
if let Ok(mut ts) = self.state.tablet_state.lock() {
ts.set_screen_size(screen);
}
}
// Feed pointer/touch events into the shared tablet state.
ui.input(|i| {
for event in &i.events {
match event {
egui::Event::PointerMoved(pos) => {
if let Ok(mut state) = self.state.tablet_state.lock() {
state.set_screen_pos(*pos);
}
}
egui::Event::Touch { force, .. } => {
if let Ok(mut state) = self.state.tablet_state.lock() {
state.set_touch_pressure(force.unwrap_or(1.0));
}
}
_ => {}
}
}
});
// Read current pressure from the shared tablet state (evdev/winit/touch).
let ts_pressure = self
.state
.tablet_state
.lock()
.map(|s| (s.current_pressure(), s.source()))
.unwrap_or((1.0, crate::app::shell::tablet_input::TabletSource::NoTablet));
self.state.current_pressure = ts_pressure.0;
log::trace!(
"canvas pressure: {:.3} source: {:?}",
ts_pressure.0,
ts_pressure.1
);
let active_pos = if self.state.is_drawing && is_active_doc {
hover_pos
} else {
response.interact_pointer_pos()
};
if let Some(pos) = active_pos {
let rel = pos - canvas_rect.min;
let cx = (rel.x / zoom) as i32;
let cy = (rel.y / zoom) as i32;
let in_bounds = cx >= 0 && cx < canvas_w as i32 && cy >= 0 && cy < canvas_h as i32;
let should_interact = is_active_doc && (in_bounds || self.state.is_drawing);
// Update cursor position only for the active document so status bar
// and panels show coordinates relevant to the document being edited.
if is_active_doc && in_bounds {
let ux = cx.clamp(0, canvas_w as i32 - 1) as u32;
let uy = cy.clamp(0, canvas_h as i32 - 1) as u32;
self.state.cursor_canvas_pos = Some((ux, uy));
}
if should_interact {
let ux = cx.clamp(0, canvas_w as i32 - 1) as u32;
let uy = cy.clamp(0, canvas_h as i32 - 1) as u32;
let primary_pressed =
ui.input(|i| i.pointer.primary_pressed()) && response.hovered();
// Reject raster edits on non-editable layers (e.g. group or mask layers)
if primary_pressed && !self.state.is_drawing {
if let Some(req_type) = self.state.active_tool.allowed_layer_type() {
if req_type == hcie_engine_api::LayerType::Raster
&& !self.doc.engine.active_layer_is_editable()
{
self.event_bus.push(AppEvent::ShowStatusMessage(
"Layer not editable".to_string(),
));
// Consume the press so we don't fall through to drawing
return response;
}
}
}
// Auto-create layer if current tool requires a different layer type
if primary_pressed && !self.state.is_drawing {
if let Some(req_type) = self.state.active_tool.allowed_layer_type() {
if req_type != hcie_engine_api::LayerType::Text {
if let Some(active) = self.doc.engine.active_layer() {
if active.layer_type != req_type {
match req_type {
hcie_engine_api::LayerType::Vector => {
let id = self.doc.engine.add_layer("Vector Layer");
self.doc.engine.set_active_layer(id);
}
_ => {
let id = self.doc.engine.add_layer("Layer");
self.doc.engine.set_active_layer(id);
}
}
self.event_bus.push(AppEvent::RenderRequested);
}
}
}
}
}
if primary_pressed {
let is_ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
let is_shift = ui.input(|i| i.modifiers.shift);
if is_ctrl {
if let Some(layer_id) = self.doc.engine.active_layer_id_opt() {
if let Some(px) = self.doc.engine.get_pixel(layer_id, ux, uy) {
self.state.primary_color = px;
}
}
self.state.is_drawing = true;
self.state.drag_start_pos = Some((ux, uy));
self.state.last_draw_pos = Some((ux, uy));
} else if is_shift
&& matches!(
self.state.active_tool,
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray
)
{
self.state.is_resizing_brush = true;
self.state.brush_resize_start_size = self.state.active_size();
self.state.brush_resize_start_pos = Some(pos);
self.state.is_drawing = true;
self.state.drag_start_pos = Some((ux, uy));
self.state.last_draw_pos = Some((ux, uy));
} else {
match self.state.active_tool {
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
if !self.doc.engine.active_layer_is_editable() {
self.event_bus.push(AppEvent::ShowStatusMessage(
"Layer not editable".to_string(),
));
return response;
}
let input_at = std::time::Instant::now();
perf::begin_stroke(input_at);
perf::record_input(input_at);
// Send current brush state to engine before stroke
self.doc.engine.set_color(self.state.primary_color);
let mut tip = hcie_engine_api::BrushTip::default();
match self.state.active_tool {
Tool::Pen => {
tip.style = hcie_engine_api::BrushStyle::Round;
tip.size = self.state.tool_configs.pen.size;
tip.opacity = self.state.tool_configs.pen.opacity;
tip.hardness = self.state.tool_configs.pen.hardness;
tip.spacing = 0.05;
}
Tool::Brush => {
tip.style = match self.state.tool_configs.brush.style {
hcie_engine_api::tools::BrushStyle::Default => {
hcie_engine_api::BrushStyle::Round
}
hcie_engine_api::tools::BrushStyle::Round => {
hcie_engine_api::BrushStyle::Round
}
hcie_engine_api::tools::BrushStyle::Square => {
hcie_engine_api::BrushStyle::Square
}
hcie_engine_api::tools::BrushStyle::HardRound => {
hcie_engine_api::BrushStyle::HardRound
}
hcie_engine_api::tools::BrushStyle::SoftRound => {
hcie_engine_api::BrushStyle::SoftRound
}
hcie_engine_api::tools::BrushStyle::Noise => {
hcie_engine_api::BrushStyle::Noise
}
hcie_engine_api::tools::BrushStyle::Texture => {
hcie_engine_api::BrushStyle::Texture
}
hcie_engine_api::tools::BrushStyle::Spray => {
hcie_engine_api::BrushStyle::Spray
}
hcie_engine_api::tools::BrushStyle::Pen => {
hcie_engine_api::BrushStyle::Pen
}
hcie_engine_api::tools::BrushStyle::Oil => {
hcie_engine_api::BrushStyle::Oil
}
hcie_engine_api::tools::BrushStyle::Charcoal => {
hcie_engine_api::BrushStyle::Charcoal
}
hcie_engine_api::tools::BrushStyle::Leaf => {
hcie_engine_api::BrushStyle::Leaf
}
hcie_engine_api::tools::BrushStyle::Rock => {
hcie_engine_api::BrushStyle::Rock
}
hcie_engine_api::tools::BrushStyle::Meadow => {
hcie_engine_api::BrushStyle::Meadow
}
hcie_engine_api::tools::BrushStyle::Wood => {
hcie_engine_api::BrushStyle::Wood
}
hcie_engine_api::tools::BrushStyle::Watercolor => {
hcie_engine_api::BrushStyle::Watercolor
}
hcie_engine_api::tools::BrushStyle::Calligraphy => {
hcie_engine_api::BrushStyle::Calligraphy
}
hcie_engine_api::tools::BrushStyle::Marker => {
hcie_engine_api::BrushStyle::Marker
}
hcie_engine_api::tools::BrushStyle::Sketch => {
hcie_engine_api::BrushStyle::Sketch
}
hcie_engine_api::tools::BrushStyle::Hatch => {
hcie_engine_api::BrushStyle::Hatch
}
hcie_engine_api::tools::BrushStyle::Star => {
hcie_engine_api::BrushStyle::Star
}
hcie_engine_api::tools::BrushStyle::Glow => {
hcie_engine_api::BrushStyle::Glow
}
hcie_engine_api::tools::BrushStyle::Airbrush => {
hcie_engine_api::BrushStyle::Airbrush
}
hcie_engine_api::tools::BrushStyle::Pencil => {
hcie_engine_api::BrushStyle::Pencil
}
hcie_engine_api::tools::BrushStyle::Crayon => {
hcie_engine_api::BrushStyle::Crayon
}
hcie_engine_api::tools::BrushStyle::WetPaint => {
hcie_engine_api::BrushStyle::WetPaint
}
hcie_engine_api::tools::BrushStyle::InkPen => {
hcie_engine_api::BrushStyle::InkPen
}
hcie_engine_api::tools::BrushStyle::Clouds => {
hcie_engine_api::BrushStyle::Clouds
}
hcie_engine_api::tools::BrushStyle::Dirt => {
hcie_engine_api::BrushStyle::Dirt
}
hcie_engine_api::tools::BrushStyle::Tree => {
hcie_engine_api::BrushStyle::Tree
}
hcie_engine_api::tools::BrushStyle::Bristle => {
hcie_engine_api::BrushStyle::Bristle
}
hcie_engine_api::tools::BrushStyle::Mixer => {
hcie_engine_api::BrushStyle::Mixer
}
hcie_engine_api::tools::BrushStyle::Blender => {
hcie_engine_api::BrushStyle::Blender
}
hcie_engine_api::tools::BrushStyle::Bitmap => {
// Preserve bitmap data from the active brush preset
let presets = &self.state.brush_presets;
if let Some(p) = presets.iter().find(|bp| {
bp.id == self.state.active_brush_preset_id
}) {
tip.bitmap_pixels = p.tip.bitmap_pixels.clone();
tip.bitmap_w = p.tip.bitmap_w;
tip.bitmap_h = p.tip.bitmap_h;
}
hcie_engine_api::BrushStyle::Bitmap
}
};
tip.size = self.state.tool_configs.brush.size;
tip.opacity = self.state.tool_configs.brush.opacity;
tip.hardness = self.state.tool_configs.brush.hardness;
tip.spacing = self.state.tool_configs.brush.spacing;
tip.color_variant =
self.state.tool_configs.brush.color_variant;
tip.variant_amount =
self.state.tool_configs.brush.variant_amount;
tip.density = self.state.tool_configs.brush.density;
tip.jitter_amount =
self.state.tool_configs.brush.jitter_amount;
tip.scatter_amount =
self.state.tool_configs.brush.scatter_amount;
tip.angle = self.state.tool_configs.brush.angle;
tip.roundness = self.state.tool_configs.brush.roundness;
tip.drawing_angle =
self.state.tool_configs.brush.drawing_angle;
tip.rotation_random =
self.state.tool_configs.brush.rotation_random;
tip.time_enabled = false;
tip.time_size_start = 1.0;
tip.time_size_end = 1.0;
tip.time_opacity_start = 1.0;
tip.time_opacity_end = 1.0;
tip.time_angle_start = 0.0;
tip.time_angle_end = 0.0;
}
Tool::Eraser => {
tip.style = hcie_engine_api::BrushStyle::Round;
tip.size = self.state.tool_configs.eraser.size;
tip.opacity = self.state.tool_configs.eraser.opacity;
tip.hardness = self.state.tool_configs.eraser.hardness;
}
Tool::Spray => {
tip.style = hcie_engine_api::BrushStyle::Spray;
tip.size = self.state.tool_configs.spray.radius;
tip.opacity = self.state.tool_configs.spray.opacity;
tip.hardness = self.state.tool_configs.spray.hardness;
tip.spacing = 0.5;
tip.spray_particle_size =
self.state.tool_configs.spray.dot_size;
tip.spray_density = self.state.tool_configs.spray.density;
}
_ => {}
}
self.doc.engine.set_brush_tip(tip);
// Sync eraser mode based on active tool
self.doc
.engine
.set_eraser(self.state.active_tool == Tool::Eraser);
self.doc
.engine
.set_cyclic_color(self.state.tool_configs.brush.cyclic_color);
self.doc
.engine
.set_cyclic_speed(self.state.tool_configs.brush.cyclic_speed);
self.state.is_drawing = true;
self.state.drag_start_pos = Some((ux, uy));
self.state.last_draw_pos = Some((ux, uy));
self.state.brush_accumulated_dist = 0.0;
let layer_id = self.doc.engine.active_layer_id();
if self.state.active_tool != Tool::Pen {
let engine_start = std::time::Instant::now();
self.doc.engine.begin_stroke(layer_id, ux as f32, uy as f32);
// Draw single dab on click (even without movement).
// The spacing check in the drag handler prevents
// re-drawing the same dab on the first move event.
self.doc.engine.stroke_to(
layer_id,
ux as f32,
uy as f32,
self.state.current_pressure,
);
perf::record_duration("engine_draw", engine_start.elapsed());
}
}
Tool::FloodFill => {
if !self.doc.engine.active_layer_is_editable() {
self.event_bus.push(AppEvent::ShowStatusMessage(
"Layer not editable".to_string(),
));
return response;
}
let color = self.state.primary_color;
self.doc.engine.flood_fill(ux, uy, color, 32);
self.event_bus.push(AppEvent::RenderRequested);
}
Tool::Select => {
self.state.is_drawing = true;
self.state.drag_start_pos = Some((ux, uy));
self.state.selection_rect = Some([ux, uy, ux, uy]);
}
Tool::MagicWand => {
// Magic Wand: single click — flood-select by colour
let tolerance = self.state.tool_configs.magic_wand.tolerance;
self.doc
.engine
.create_selection_magic_wand(ux, uy, tolerance);
self.event_bus.push(AppEvent::RenderRequested);
}
Tool::SmartSelect => {
// Smart Select: fire immediately on click (like MagicWand).
// Do NOT also fire on release; that would double-run the AI/fallback.
self.event_bus
.push(AppEvent::SmartSelectRun { x: ux, y: uy });
}
Tool::VisionSelect => {
// VisionSelect: start a drag that becomes either a point click
// (small drag) or a bounding-box prompt (large drag) on release.
self.state.is_drawing = true;
self.state.drag_start_pos = Some((ux, uy));
self.state.last_draw_pos = Some((ux, uy));
self.state.vision_select_rect = Some([ux, uy, ux, uy]);
}
Tool::Lasso => {
self.state.is_drawing = true;
self.state.lasso_points.clear();
self.state.lasso_points.push((ux, uy));
self.state.drag_start_pos = Some((ux, uy));
}
Tool::PolygonSelect => {
// PolygonSelect: accumulate vertices on each click;
// close the polygon when clicking near the start point
// or when the point list already has >=3 points and
// the user double-clicks (distance to last point ≤ 4px).
let close_dist = 10i32;
let closed = if self.state.lasso_points.len() >= 3 {
// Check distance to first point
let (fx, fy) = self.state.lasso_points[0];
let dx = fx as i32 - ux as i32;
let dy = fy as i32 - uy as i32;
let near_start = dx * dx + dy * dy <= close_dist * close_dist;
// Also check distance to last point (double-click)
let last = *self.state.lasso_points.last().unwrap();
let dlx = last.0 as i32 - ux as i32;
let dly = last.1 as i32 - uy as i32;
let near_last = dlx * dlx + dly * dly <= 4;
near_start || near_last
} else {
false
};
if closed {
// Finalise polygon
if self.state.lasso_points.len() >= 3 {
self.doc
.engine
.create_selection_polygon(&self.state.lasso_points);
self.event_bus.push(AppEvent::RenderRequested);
}
self.state.lasso_points.clear();
} else {
self.state.lasso_points.push((ux, uy));
}
}
Tool::Move => {
if let Some(tr) = &mut self.state.transform {
let m_pos = ui.input(|i| {
i.pointer
.hover_pos()
.unwrap_or(egui::pos2(-1000.0, -1000.0))
});
self.state.transform_drag_handle =
render::get_transform_handle_at(
m_pos,
canvas_rect,
tr,
zoom,
);
} else {
let mask = self.doc.engine.get_selection_mask();
if let Some(mask_data) = mask {
let w = self.doc.engine.canvas_width();
let h = self.doc.engine.canvas_height();
if ux < w && uy < h && mask_data[(uy * w + ux) as usize] > 0
{
self.state.transform =
Some(SelectionTransform::from_engine(
&self.doc.engine,
mask_data,
w,
h,
));
// Only clear pixels within the selection area, not the entire layer.
if let Some(mut layer_pixels) =
self.doc.engine.get_active_layer_pixels()
{
let cw = w as usize;
let ch = h as usize;
for y in 0..ch {
for x in 0..cw {
let midx = y * cw + x;
if midx < mask_data.len()
&& mask_data[midx] > 0
{
let pidx = midx * 4;
if pidx + 3 < layer_pixels.len() {
layer_pixels[pidx..pidx + 4]
.fill(0);
}
}
}
}
self.doc
.engine
.set_active_layer_pixels(layer_pixels);
}
self.doc.engine.selection_clear();
self.event_bus.push(AppEvent::RenderRequested);
}
}
}
}
Tool::SmartPatch
| Tool::SpotRemoval
| Tool::RedEyeRemoval
| Tool::AiObjectRemoval => {
if matches!(self.state.active_tool, Tool::AiObjectRemoval) {
self.state.smart_patch_points.clear();
}
self.state.is_drawing = true;
self.state.drag_start_pos = Some((ux, uy));
self.state.last_draw_pos = Some((ux, uy));
self.state.smart_patch_points.push((ux, uy));
}
Tool::VectorSelect => {
// Check handle hit on currently selected shape first
let handle_hit = self.state.selected_vector_shape.and_then(|idx| {
let layer = self.doc.engine.active_layer()?;
match &layer.data {
LayerData::Vector { shapes } => {
let shape = shapes.get(idx)?;
let screen_pos = egui::pos2(pos.x, pos.y);
Some(render::get_vector_handle_at(
screen_pos,
canvas_rect,
shape,
zoom,
))
}
_ => None,
}
});
if let Some(handle) = handle_hit {
if handle != VectorEditHandle::None {
self.state.vector_edit_handle = handle;
// Save snapshot for Apply/Cancel
if self.state.vector_shapes_snapshot.is_none() {
if let Some(shapes) =
self.doc.engine.active_vector_shapes()
{
self.state.vector_shapes_snapshot = Some(shapes);
}
}
} else {
// Clicked on shape body with Move handle - select this shape
self.state.vector_edit_handle = VectorEditHandle::Move;
if let Some(shapes) = self.doc.engine.active_vector_shapes()
{
self.state.vector_shapes_snapshot = Some(shapes);
}
}
} else if let Some(mut idx) =
self.doc.engine.find_vector_shape_at(ux as f32, uy as f32)
{
// If the shapes are overlapping in the same region and the
// currently selected shape is at this point, cycle to the
// next shape below so the user can reach both.
if Some(idx) == self.state.selected_vector_shape {
if let Some(shapes) = self.doc.engine.active_vector_shapes()
{
// Find another shape at this point (iterating upward
// from shape 0 so we get the next shape below the
// currently selected one).
let mut found_other = false;
for (i, shape) in shapes.iter().enumerate() {
if i == idx {
continue;
}
let (l, t, r, b) = shape.normalized_bounds();
if ux as f32 >= l
&& ux as f32 <= r
&& uy as f32 >= t
&& uy as f32 <= b
{
idx = i;
found_other = true;
break;
}
}
// If no other shape found at this point, keep the
// current selection.
if !found_other {
idx =
self.state.selected_vector_shape.unwrap_or(idx);
}
}
}
self.state.selected_vector_shape = Some(idx);
self.state.vector_edit_handle = VectorEditHandle::None;
// Save snapshot for property edits
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
self.state.vector_shapes_snapshot = Some(shapes);
}
// Sync tool state from selected shape
shape_sync::sync_tool_from_shape(self.doc, self.state);
} else {
self.state.selected_vector_shape = None;
self.state.vector_edit_handle = VectorEditHandle::None;
self.state.vector_shapes_snapshot = None;
}
self.state.is_drawing = true;
self.state.drag_start_pos = Some((ux, uy));
}
Tool::Eyedropper => {
if let Some(layer_id) = self.doc.engine.active_layer_id_opt() {
if let Some(px) = self.doc.engine.get_pixel(layer_id, ux, uy) {
self.state.primary_color = px;
}
}
}
Tool::Text => {
// Hit-test existing text layers first. If the click lands on a
// text layer (single click while Text tool active), enter
// edit mode for that layer instead of creating a new draft.
if let Some(layer_id) =
find_text_layer_at(&self.doc.engine, ux as f32, uy as f32)
{
self.event_bus.push(
crate::event_bus::AppEvent::TextLayerSelected(layer_id),
);
} else {
self.event_bus.push(
crate::event_bus::AppEvent::ToolTextStart { x: ux, y: uy },
);
}
}
tool if tool.is_shape_tool() => {
self.state.is_drawing = true;
self.state.drag_start_pos = Some((ux, uy));
self.state.last_draw_pos = Some((ux, uy));
}
_ => {}
}
}
}
let is_dragging = response.dragged_by(egui::PointerButton::Primary)
|| (primary_down
&& self.state.is_drawing
&& self
.state
.last_draw_pos
.map_or(true, |(lx, ly)| lx != ux || ly != uy));
let is_move_dragging = response.dragged_by(egui::PointerButton::Primary)
&& self.state.active_tool == Tool::Move
&& self.state.transform.is_some();
if is_move_dragging {
if let Some(tr) = &mut self.state.transform {
let delta = response.drag_delta();
let canvas_dx = delta.x / zoom;
let canvas_dy = delta.y / zoom;
let aspect = if tr.width > 0 {
tr.height as f32 / tr.width as f32
} else {
1.0
};
let shift = ui.input(|i| i.modifiers.shift);
match self.state.transform_drag_handle {
TransformHandle::Move => {
tr.pos.x += canvas_dx;
tr.pos.y += canvas_dy;
}
TransformHandle::TopLeft => {
tr.pos.x += canvas_dx;
tr.pos.y += canvas_dy;
tr.size.x -= canvas_dx;
tr.size.y -= canvas_dy;
if shift {
let s = tr.size.x.max(tr.size.y);
let new_h = s * aspect;
tr.pos.y += tr.size.y - new_h;
tr.size.y = new_h;
tr.size.x = s;
}
}
TransformHandle::TopRight => {
tr.pos.y += canvas_dy;
tr.size.x += canvas_dx;
tr.size.y -= canvas_dy;
if shift {
let s = tr.size.x.max(tr.size.y);
let new_h = s * aspect;
tr.pos.y += tr.size.y - new_h;
tr.size.y = new_h;
tr.size.x = s;
}
}
TransformHandle::BottomRight => {
tr.size.x += canvas_dx;
tr.size.y += canvas_dy;
if shift {
let s = tr.size.x.max(tr.size.y);
tr.size.y = s * aspect;
tr.size.x = s;
}
}
TransformHandle::BottomLeft => {
tr.pos.x += canvas_dx;
tr.size.x -= canvas_dx;
tr.size.y += canvas_dy;
if shift {
let s = tr.size.x.max(tr.size.y);
let new_w = s;
let new_h = s * aspect;
tr.pos.x += tr.size.x - new_w;
tr.size.x = new_w;
tr.size.y = new_h;
}
}
TransformHandle::Top => {
tr.pos.y += canvas_dy;
tr.size.y -= canvas_dy;
if shift {
let new_w = tr.size.y / aspect;
let old_cx = tr.pos.x + tr.size.x * 0.5;
tr.pos.x = old_cx - new_w * 0.5;
tr.size.x = new_w;
}
}
TransformHandle::Bottom => {
tr.size.y += canvas_dy;
if shift {
let new_w = tr.size.y / aspect;
let old_cx = tr.pos.x + tr.size.x * 0.5;
tr.pos.x = old_cx - new_w * 0.5;
tr.size.x = new_w;
}
}
TransformHandle::Left => {
tr.pos.x += canvas_dx;
tr.size.x -= canvas_dx;
if shift {
let new_h = tr.size.x * aspect;
let old_cy = tr.pos.y + tr.size.y * 0.5;
tr.pos.y = old_cy - new_h * 0.5;
tr.size.y = new_h;
}
}
TransformHandle::Right => {
tr.size.x += canvas_dx;
if shift {
let new_h = tr.size.x * aspect;
let old_cy = tr.pos.y + tr.size.y * 0.5;
tr.pos.y = old_cy - new_h * 0.5;
tr.size.y = new_h;
}
}
TransformHandle::None => {}
}
tr.size.x = tr.size.x.max(1.0);
tr.size.y = tr.size.y.max(1.0);
}
}
if is_dragging && self.state.is_drawing {
let is_ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
if is_ctrl {
if let Some(layer_id) = self.doc.engine.active_layer_id_opt() {
if let Some(px) = self.doc.engine.get_pixel(layer_id, ux, uy) {
self.state.primary_color = px;
}
}
} else if self.state.is_resizing_brush {
if let Some(start_pos) = self.state.brush_resize_start_pos {
let current_pos =
ui.input(|i| i.pointer.hover_pos().unwrap_or(start_pos));
let dx = current_pos.x - start_pos.x;
let new_size =
(self.state.brush_resize_start_size + dx).clamp(1.0, 500.0);
match self.state.active_tool {
Tool::Pen => self.state.tool_configs.pen.size = new_size,
Tool::Brush => self.state.tool_configs.brush.size = new_size,
Tool::Eraser => self.state.tool_configs.eraser.size = new_size,
Tool::Spray => self.state.tool_configs.spray.radius = new_size,
_ => {}
}
}
} else {
log::trace!("canvas drag segment: ux={}, uy={}", ux, uy);
match self.state.active_tool {
Tool::Pen => {
if let Some((lx, ly)) = self.state.last_draw_pos {
let input_at = std::time::Instant::now();
perf::record_input(input_at);
let engine_start = std::time::Instant::now();
let layer_id = self.doc.engine.active_layer_id();
if !self.doc.engine.is_stroke_active() {
self.doc
.engine
.begin_stroke(layer_id, lx as f32, ly as f32);
}
self.doc.engine.draw_pen_segment(
layer_id,
lx as f32,
ly as f32,
ux as f32,
uy as f32,
self.state.current_pressure,
);
perf::record_duration("engine_draw", engine_start.elapsed());
self.state.last_draw_pos = Some((ux, uy));
}
}
Tool::Brush | Tool::Eraser | Tool::Spray => {
if let Some(last) = self.state.last_draw_pos {
let dx = (ux as f32 - last.0 as f32)
.hypot(uy as f32 - last.1 as f32);
self.state.brush_accumulated_dist += dx;
// Pointer-event downsampling: do not forward every sub-pixel motion
// event to the engine. On 4K canvases each stroke_to call triggers
// dirty-bound expansion and tile sync; coalescing events reduces
// that overhead without visible quality loss for basic brush tools.
let spacing = match self.state.active_tool {
Tool::Spray => self.state.tool_configs.spray.radius * 0.25,
_ => self.state.tool_configs.brush.size * 0.12,
}
.max(1.0);
if dx >= spacing {
let input_at = std::time::Instant::now();
perf::record_input(input_at);
let engine_start = std::time::Instant::now();
let layer_id = self.doc.engine.active_layer_id();
self.doc.engine.stroke_to(
layer_id,
ux as f32,
uy as f32,
self.state.current_pressure,
);
perf::record_duration(
"engine_draw",
engine_start.elapsed(),
);
self.state.last_draw_pos = Some((ux, uy));
}
}
}
Tool::Select => {
if let Some(rect) = self.state.selection_rect.as_mut() {
rect[2] = ux;
rect[3] = uy;
}
}
Tool::Lasso => {
if let Some(&(lx, ly)) = self.state.lasso_points.last() {
if (lx as i32 - ux as i32).abs() > 2
|| (ly as i32 - uy as i32).abs() > 2
{
self.state.lasso_points.push((ux, uy));
}
}
}
Tool::VisionSelect => {
self.state.last_draw_pos = Some((ux, uy));
if let Some(rect) = self.state.vision_select_rect.as_mut() {
rect[2] = ux;
rect[3] = uy;
}
self.event_bus.push(AppEvent::RenderRequested);
}
Tool::Move => {
// Move tool drag is handled above in is_move_dragging block
}
Tool::SmartPatch
| Tool::SpotRemoval
| Tool::RedEyeRemoval
| Tool::AiObjectRemoval => {
self.state.smart_patch_points.push((ux, uy));
self.state.last_draw_pos = Some((ux, uy));
}
Tool::VectorSelect => {
if let Some(shape_idx) = self.state.selected_vector_shape {
let handle = self.state.vector_edit_handle;
let layer_id = self.doc.engine.active_layer_id();
let delta = response.drag_delta();
let canvas_dx = delta.x / zoom;
let canvas_dy = delta.y / zoom;
if handle == VectorEditHandle::Rotate {
// Rotation: compute angle delta from mouse movement around shape center
if let Some((cx, cy)) = self
.doc
.engine
.get_vector_shape_center(layer_id, shape_idx)
{
let curr_angle = (uy as f32 - cy).atan2(ux as f32 - cx);
let prev_angle = if let Some(prev_pos) =
self.state.vector_drag_last
{
(prev_pos.y - cy).atan2(prev_pos.x - cx)
} else {
curr_angle
};
let da = curr_angle - prev_angle;
let mut angle = self
.doc
.engine
.vector_shape_angle(layer_id, shape_idx);
angle += da;
let shift = ui.input(|i| i.modifiers.shift);
if shift {
let step = std::f32::consts::PI / 12.0;
angle = (angle / step).round() * step;
}
self.doc
.engine
.set_vector_shape_angle(layer_id, shape_idx, angle);
self.state.vector_drag_last =
Some(egui::pos2(ux as f32, uy as f32));
if self.doc.engine.get_svg_source(layer_id).is_some() {
let w = self.doc.engine.canvas_width();
let h = self.doc.engine.canvas_height();
if let Some(shapes) =
self.doc.engine.active_vector_shapes()
{
crate::app::io::custom_loaders_kra_v2::rerasterize_svg_layer(&mut self.doc.engine, layer_id, &shapes, w, h);
}
}
self.event_bus.push(AppEvent::RenderRequested);
}
} else {
self.state.vector_drag_last = None;
let effective_handle = if handle == VectorEditHandle::None {
VectorEditHandle::Move
} else {
handle
};
self.doc.engine.modify_vector_shape(
layer_id,
shape_idx,
effective_handle,
canvas_dx,
canvas_dy,
);
if self.doc.engine.get_svg_source(layer_id).is_some() {
let w = self.doc.engine.canvas_width();
let h = self.doc.engine.canvas_height();
if let Some(shapes) =
self.doc.engine.active_vector_shapes()
{
crate::app::io::custom_loaders_kra_v2::rerasterize_svg_layer(&mut self.doc.engine, layer_id, &shapes, w, h);
}
}
self.event_bus.push(AppEvent::RenderRequested);
}
}
}
tool if tool.is_shape_tool() => {
self.state.last_draw_pos = Some((ux, uy));
self.event_bus.push(AppEvent::RenderRequested);
}
_ => {}
}
}
}
}
}
let is_ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
if (response.drag_stopped_by(egui::PointerButton::Primary) || primary_released)
&& was_drawing
{
log::debug!(
"Canvas stroke finished (egui_stopped: {}, fallback_released: {})",
response.drag_stopped_by(egui::PointerButton::Primary),
primary_released
);
let was_resizing = self.state.is_resizing_brush;
self.state.is_resizing_brush = false;
if !was_resizing && !is_ctrl {
match self.state.active_tool {
Tool::Move => {
if let Some(tr) = self.state.transform.take() {
apply_transform_to_layer(&mut self.doc.engine, &tr);
self.state.transform_drag_handle = TransformHandle::None;
self.event_bus.push(AppEvent::RenderRequested);
}
}
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
let t_stroke_end = std::time::Instant::now();
let layer_id = self.doc.engine.active_layer_id();
self.doc.engine.end_stroke(layer_id);
log::warn!(
"[PERF] self.doc.engine.end_stroke took {}ms",
t_stroke_end.elapsed().as_millis()
);
self.doc.last_stroke_end_time = Some(std::time::Instant::now());
let desc = match self.state.active_tool {
Tool::Pen => "Pen Stroke".to_string(),
Tool::Eraser => "Eraser Stroke".to_string(),
Tool::Spray => "Spray Stroke".to_string(),
Tool::Brush => match self.state.tool_configs.brush.style {
hcie_engine_api::tools::BrushStyle::Default => {
"Default Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Round => {
"Round Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Square => {
"Square Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::HardRound => {
"Hard Round Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::SoftRound => {
"Soft Round Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Noise => {
"Noise Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Texture => {
"Texture Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Spray => {
"Spray Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Pen => {
"Pen Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Oil => {
"Oil Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Charcoal => {
"Charcoal Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Leaf => {
"Leaf Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Rock => {
"Rock Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Meadow => {
"Meadow Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Wood => {
"Wood Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Watercolor => {
"Watercolor Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Calligraphy => {
"Calligraphy Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Marker => {
"Marker Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Sketch => {
"Sketch Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Hatch => {
"Hatch Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Star => {
"Star Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Glow => {
"Glow Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Airbrush => {
"Airbrush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Pencil => {
"Pencil Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Crayon => {
"Crayon Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::WetPaint => {
"Wet Paint Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::InkPen => {
"Ink Pen Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Clouds => {
"Clouds Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Dirt => {
"Dirt Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Tree => {
"Tree Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Bristle => {
"Bristle Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Mixer => {
"Mixer Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Blender => {
"Blender Brush Stroke".to_string()
}
hcie_engine_api::tools::BrushStyle::Bitmap => {
"Bitmap Brush Stroke".to_string()
}
},
_ => "Brush Stroke".to_string(),
};
self.doc.record_history_description(desc);
self.event_bus.push(AppEvent::RenderRequested);
self.event_bus.push(AppEvent::DrawingFinished);
perf::request_finish();
}
Tool::Select => {
if let Some(rect) = self.state.selection_rect {
let x1 = rect[0].min(rect[2]);
let y1 = rect[1].min(rect[3]);
let x2 = rect[0].max(rect[2]);
let y2 = rect[1].max(rect[3]);
self.doc.engine.create_selection_rect(x1, y1, x2, y2);
self.state.selection_rect = None;
self.event_bus.push(AppEvent::RenderRequested);
}
}
Tool::MagicWand => {
// Magic Wand fires on press (primary_pressed), not on drag release.
// Nothing extra needed here — reset drawing state only.
}
Tool::Lasso => {
if self.state.lasso_points.len() >= 3 {
self.doc
.engine
.create_selection_lasso(&self.state.lasso_points);
self.event_bus.push(AppEvent::RenderRequested);
}
self.state.lasso_points.clear();
}
Tool::PolygonSelect => {
// PolygonSelect is finalised on click (primary_pressed path above).
// drag_stopped here means the user dragged slightly while clicking —
// treat as a normal vertex add (already done), no extra action needed.
}
Tool::SmartPatch => {
self.event_bus.push(AppEvent::SmartPatch);
}
Tool::SmartSelect => {
// SmartSelect already fired on primary press; do not fire again on release.
}
Tool::VisionSelect => {
// Finish a VisionSelect interaction. If the user dragged a meaningful
// distance, treat it as a bounding-box prompt for SAM; otherwise use the
// click point as a point prompt.
if let (Some((x1, y1)), Some((x2, y2))) =
(self.state.drag_start_pos, self.state.last_draw_pos)
{
let dx = x1 as i32 - x2 as i32;
let dy = y1 as i32 - y2 as i32;
if dx * dx + dy * dy > 4 {
self.event_bus.push(AppEvent::VisionSelectRun {
x1: x1.min(x2),
y1: y1.min(y2),
x2: x1.max(x2),
y2: y1.max(y2),
});
} else {
self.event_bus.push(AppEvent::VisionSelectRun {
x1: x1,
y1: y1,
x2: x1,
y2: y1,
});
}
}
self.state.vision_select_rect = None;
}
Tool::SpotRemoval => {
self.event_bus.push(AppEvent::SpotRemoval);
}
Tool::RedEyeRemoval => {
self.event_bus.push(AppEvent::RedEyeRemoval);
}
Tool::AiObjectRemoval => {
// handled via smart_patch_points
}
tool if tool.is_shape_tool() => {
if let (Some((x1, y1)), Some((x2, y2))) =
(self.state.drag_start_pos, self.state.last_draw_pos)
{
let shape = create_shape_from_drag(
tool,
x1 as f32,
y1 as f32,
x2 as f32,
y2 as f32,
&self.state,
&self.doc.engine.shape_catalog,
);
self.doc.engine.add_vector_shape(shape);
// Auto-select the newly created shape
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
self.state.selected_vector_shape = Some(shapes.len() - 1);
self.state.vector_shapes_snapshot = Some(shapes.clone());
}
self.event_bus.push(AppEvent::RenderRequested);
self.event_bus.push(AppEvent::DrawingFinished);
}
}
Tool::VectorSelect => {
self.state.vector_drag_last = None;
}
_ => {}
}
}
self.state.is_drawing = false;
self.state.drag_start_pos = None;
self.state.last_draw_pos = None;
self.event_bus.push(AppEvent::SaveSettings);
}
// ── Render composite texture ───────────────────────────
let created_new_texture =
render::render_composition(ui.ctx(), self.doc, self.state, canvas_rect, zoom);
// egui uploads textures asynchronously — the frame that creates the texture
// will NOT display it yet. Request multiple repaints so the GPU upload completes
// and the canvas appears without requiring a user click on the tab title.
// Two frames are the minimum; a third covers slow GPU drivers.
if created_new_texture {
ui.ctx().request_repaint();
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(33));
}
// Checkerboard behind transparent areas
// Batched into a single draw call to avoid 9K+ individual primitive allocations.
// Tiles are aligned to the screen origin (0,0) so the pattern stays
// perfectly static during zoom/pan instead of shifting with canvas_rect.
{
let tile_size = 30.0;
let color_a = egui::Color32::from_gray(170);
let color_b = egui::Color32::from_gray(200);
let mut shapes: Vec<egui::Shape> = Vec::with_capacity(2048);
let x_start = 0.0f32;
let y_start = 0.0f32;
let mut x = x_start;
let mut row = 0i32;
while x < canvas_rect.max.x {
let mut y = y_start;
let mut col = 0i32;
while y < canvas_rect.max.y {
let tile_rect = egui::Rect::from_min_max(
egui::pos2(x, y),
egui::pos2(x + tile_size, y + tile_size),
);
if tile_rect.intersects(canvas_rect) {
let fill = if (row + col) & 1 == 0 {
color_a
} else {
color_b
};
let clipped = tile_rect.intersect(canvas_rect);
shapes.push(egui::Shape::rect_filled(clipped, 0.0, fill));
}
y += tile_size;
col += 1;
}
x += tile_size;
row += 1;
}
painter.extend(shapes);
}
// Paint the texture
if let Some(tex) = &self.doc.composite_texture {
painter.image(
tex.id(),
canvas_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
} else {
painter.rect_filled(canvas_rect, 0.0, egui::Color32::from_gray(40));
}
// Canvas border around composited texture area
painter.rect_stroke(
canvas_rect.expand(1.0),
0.0,
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
egui::StrokeKind::Outside,
);
// Vector handle overlay (must be after texture paint)
render::draw_vector_handles(&painter, ui.ctx(), self.doc, self.state, canvas_rect, zoom);
// ── Shape tool realtime preview ──────────────────────────
// Draws a bright preview while the user is dragging a shape.
// This is drawn directly on top of the canvas so it is visible even
// before the engine has composited the final shape.
//
// DO NOT REMOVE OR BYPASS THIS PREVIEW. Removing it causes the shape tool
// to feel broken because the user sees no feedback while dragging.
if self.state.active_tool.is_shape_tool() {
if let Some((x1, y1)) = self.state.drag_start_pos {
// Use last drag position if available, otherwise fall back to the
// current cursor position so the preview appears immediately.
let (x2, y2) = self
.state
.last_draw_pos
.or(self.state.cursor_canvas_pos.map(|p| (p.0, p.1)))
.unwrap_or((x1, y1));
let sx = x1.min(x2) as f32;
let sy = y1.min(y2) as f32;
let ex = x1.max(x2) as f32;
let ey = y1.max(y2) as f32;
// Always draw a high-visibility filled guide rectangle so the
// user can see the drag area even for tools that render a thin
// outline (line, polygon, etc.).
let guide_rect = egui::Rect::from_min_max(
canvas_rect.min + egui::vec2(sx * zoom, sy * zoom),
canvas_rect.min + egui::vec2(ex * zoom, ey * zoom),
);
painter.rect_filled(
guide_rect,
0.0,
egui::Color32::from_rgba_unmultiplied(0, 200, 255, 40),
);
let preview_stroke = egui::Stroke::new(2.0, egui::Color32::from_rgb(0, 220, 255));
draw_shape_tool_preview(
&painter,
self.state.active_tool,
canvas_rect.min,
zoom,
sx,
sy,
ex,
ey,
preview_stroke,
&self.state.tool_configs.vector,
);
}
}
// ── Brush cursor preview ─────────────────────────────────
// Renders the active brush tip as a single-dab stamp texture. The texture is
// cached by style, size, hardness, opacity, color, and zoom, and is regenerated
// only when one of those parameters changes.
if let Some((cx, cy)) = self.state.cursor_canvas_pos {
if self.state.active_tool.shows_pen_tips() {
let screen_x = canvas_rect.min.x + cx as f32 * zoom;
let screen_y = canvas_rect.min.y + cy as f32 * zoom;
// Build a live tip from the active tool config so the preview matches
// the Properties panel in real time.
let mut tip = hcie_engine_api::BrushTip::default();
match self.state.active_tool {
Tool::Pen => {
tip.style = hcie_engine_api::BrushStyle::Round;
tip.size = self.state.tool_configs.pen.size;
tip.opacity = self.state.tool_configs.pen.opacity;
tip.hardness = self.state.tool_configs.pen.hardness;
}
Tool::Eraser => {
tip.style = hcie_engine_api::BrushStyle::Round;
tip.size = self.state.tool_configs.eraser.size;
tip.opacity = self.state.tool_configs.eraser.opacity;
tip.hardness = self.state.tool_configs.eraser.hardness;
}
Tool::Spray => {
tip.style = hcie_engine_api::BrushStyle::Spray;
tip.size = self.state.tool_configs.spray.radius;
tip.opacity = self.state.tool_configs.spray.opacity;
tip.hardness = self.state.tool_configs.spray.hardness;
}
Tool::Brush | _ => {
tip.style = match self.state.tool_configs.brush.style {
hcie_engine_api::tools::BrushStyle::Default => {
hcie_engine_api::BrushStyle::Round
}
hcie_engine_api::tools::BrushStyle::Round => {
hcie_engine_api::BrushStyle::Round
}
hcie_engine_api::tools::BrushStyle::Square => {
hcie_engine_api::BrushStyle::Square
}
hcie_engine_api::tools::BrushStyle::HardRound => {
hcie_engine_api::BrushStyle::HardRound
}
hcie_engine_api::tools::BrushStyle::SoftRound => {
hcie_engine_api::BrushStyle::SoftRound
}
hcie_engine_api::tools::BrushStyle::Noise => {
hcie_engine_api::BrushStyle::Noise
}
hcie_engine_api::tools::BrushStyle::Texture => {
hcie_engine_api::BrushStyle::Texture
}
hcie_engine_api::tools::BrushStyle::Spray => {
hcie_engine_api::BrushStyle::Spray
}
hcie_engine_api::tools::BrushStyle::Pen => {
hcie_engine_api::BrushStyle::Pen
}
hcie_engine_api::tools::BrushStyle::Oil => {
hcie_engine_api::BrushStyle::Oil
}
hcie_engine_api::tools::BrushStyle::Charcoal => {
hcie_engine_api::BrushStyle::Charcoal
}
hcie_engine_api::tools::BrushStyle::Leaf => {
hcie_engine_api::BrushStyle::Leaf
}
hcie_engine_api::tools::BrushStyle::Rock => {
hcie_engine_api::BrushStyle::Rock
}
hcie_engine_api::tools::BrushStyle::Meadow => {
hcie_engine_api::BrushStyle::Meadow
}
hcie_engine_api::tools::BrushStyle::Wood => {
hcie_engine_api::BrushStyle::Wood
}
hcie_engine_api::tools::BrushStyle::Watercolor => {
hcie_engine_api::BrushStyle::Watercolor
}
hcie_engine_api::tools::BrushStyle::Calligraphy => {
hcie_engine_api::BrushStyle::Calligraphy
}
hcie_engine_api::tools::BrushStyle::Marker => {
hcie_engine_api::BrushStyle::Marker
}
hcie_engine_api::tools::BrushStyle::Sketch => {
hcie_engine_api::BrushStyle::Sketch
}
hcie_engine_api::tools::BrushStyle::Hatch => {
hcie_engine_api::BrushStyle::Hatch
}
hcie_engine_api::tools::BrushStyle::Star => {
hcie_engine_api::BrushStyle::Star
}
hcie_engine_api::tools::BrushStyle::Glow => {
hcie_engine_api::BrushStyle::Glow
}
hcie_engine_api::tools::BrushStyle::Airbrush => {
hcie_engine_api::BrushStyle::Airbrush
}
hcie_engine_api::tools::BrushStyle::Pencil => {
hcie_engine_api::BrushStyle::Pencil
}
hcie_engine_api::tools::BrushStyle::Crayon => {
hcie_engine_api::BrushStyle::Crayon
}
hcie_engine_api::tools::BrushStyle::WetPaint => {
hcie_engine_api::BrushStyle::WetPaint
}
hcie_engine_api::tools::BrushStyle::InkPen => {
hcie_engine_api::BrushStyle::InkPen
}
hcie_engine_api::tools::BrushStyle::Clouds => {
hcie_engine_api::BrushStyle::Clouds
}
hcie_engine_api::tools::BrushStyle::Dirt => {
hcie_engine_api::BrushStyle::Dirt
}
hcie_engine_api::tools::BrushStyle::Tree => {
hcie_engine_api::BrushStyle::Tree
}
hcie_engine_api::tools::BrushStyle::Bristle => {
hcie_engine_api::BrushStyle::Bristle
}
hcie_engine_api::tools::BrushStyle::Mixer => {
hcie_engine_api::BrushStyle::Mixer
}
hcie_engine_api::tools::BrushStyle::Blender => {
hcie_engine_api::BrushStyle::Blender
}
hcie_engine_api::tools::BrushStyle::Bitmap => {
hcie_engine_api::BrushStyle::Bitmap
}
};
tip.size = self.state.tool_configs.brush.size;
tip.opacity = self.state.tool_configs.brush.opacity;
tip.hardness = self.state.tool_configs.brush.hardness;
tip.spacing = self.state.tool_configs.brush.spacing;
tip.jitter_amount = self.state.tool_configs.brush.jitter_amount;
tip.scatter_amount = self.state.tool_configs.brush.scatter_amount;
tip.angle = self.state.tool_configs.brush.angle;
tip.roundness = self.state.tool_configs.brush.roundness;
tip.density = self.state.tool_configs.brush.density;
tip.color_variant = self.state.tool_configs.brush.color_variant;
tip.variant_amount = self.state.tool_configs.brush.variant_amount;
tip.drawing_angle = self.state.tool_configs.brush.drawing_angle;
tip.rotation_random = self.state.tool_configs.brush.rotation_random;
}
}
// For bitmap (ABR) brushes, copy the imported alpha mask from the active
// preset so the preview shows the actual brush shape, but keep the live
// size from the Properties panel so scaling works correctly.
let mut is_bitmap = false;
if tip.style == hcie_engine_api::BrushStyle::Bitmap {
if let Some(preset) = self
.state
.brush_presets
.iter()
.find(|p| p.id == self.state.active_brush_preset_id)
{
if !preset.tip.bitmap_pixels.is_empty()
&& preset.tip.bitmap_w > 0
&& preset.tip.bitmap_h > 0
{
tip.bitmap_pixels = preset.tip.bitmap_pixels.clone();
tip.bitmap_w = preset.tip.bitmap_w;
tip.bitmap_h = preset.tip.bitmap_h;
// aspect ratio is preserved by scaling to the square stamp
is_bitmap = true;
}
}
}
let display_size = self.state.active_size();
// active_size() returns the diameter of the brush.
// The preview texture must cover this diameter in screen space.
let diameter_px = display_size * zoom;
let tex_size = (diameter_px.ceil() as u32).clamp(1, 256);
// Cache key: hash all stamp-relevant tip fields + color + zoom + active preset ID
let mut hasher = std::collections::hash_map::DefaultHasher::new();
tip.style.hash(&mut hasher);
display_size.to_bits().hash(&mut hasher);
tip.hardness.to_bits().hash(&mut hasher);
tip.opacity.to_bits().hash(&mut hasher);
self.state.primary_color.hash(&mut hasher);
self.state.active_brush_preset_id.hash(&mut hasher);
zoom.to_bits().hash(&mut hasher);
if is_bitmap {
tip.bitmap_w.hash(&mut hasher);
tip.bitmap_h.hash(&mut hasher);
tip.bitmap_pixels.len().hash(&mut hasher);
if !tip.bitmap_pixels.is_empty() {
let end = tip.bitmap_pixels.len().min(256);
hasher.write(&tip.bitmap_pixels[..end]);
}
}
let cache_hash = hasher.finish();
let cache_id = ui.id().with("cursor_stamp");
let cached_hash = ui.data(|d| d.get_temp::<u64>(cache_id));
if cached_hash != Some(cache_hash) {
let pixels = hcie_engine_api::Engine::render_brush_stamp_preview(
tex_size,
tex_size,
&tip,
self.state.primary_color,
);
let image = egui::ColorImage::from_rgba_unmultiplied(
[tex_size as usize, tex_size as usize],
&pixels,
);
let tex = ui.ctx().load_texture(
&format!("cursor_stamp_{:x}", cache_hash),
image,
egui::TextureOptions::LINEAR,
);
ui.data_mut(|d| {
d.insert_temp(cache_id, cache_hash);
d.insert_temp(ui.id().with("cursor_stamp_tex"), tex);
});
}
if let Some(tex) =
ui.data(|d| d.get_temp::<egui::TextureHandle>(ui.id().with("cursor_stamp_tex")))
{
// Display the stamp preview at the correct diameter.
// The texture was rendered at tex_size × tex_size pixels;
// display it at diameter_px × diameter_px screen pixels so
// the preview matches the actual brush footprint.
let display_dim = egui::vec2(diameter_px, diameter_px);
let rect =
egui::Rect::from_center_size(egui::pos2(screen_x, screen_y), display_dim);
painter.add(egui::Shape::image(
tex.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
));
// Draw a thin crosshair at the center of the stamp preview
// so the user can see the exact brush center point.
let cross_color = egui::Color32::from_rgba_unmultiplied(200, 200, 200, 160);
let cross_len = 5.0_f32;
painter.line_segment(
[
egui::pos2(screen_x - cross_len, screen_y),
egui::pos2(screen_x + cross_len, screen_y),
],
egui::Stroke::new(1.0, cross_color),
);
painter.line_segment(
[
egui::pos2(screen_x, screen_y - cross_len),
egui::pos2(screen_x, screen_y + cross_len),
],
egui::Stroke::new(1.0, cross_color),
);
}
}
}
// ── Selection overlay ──────────────────────────────────
// Draw semi-transparent fill for selected region + marching ants
if self.doc.engine.is_selection_active() {
if let Some(mask) = self.doc.engine.get_selection_mask() {
let w = self.doc.engine.canvas_width();
let h = self.doc.engine.canvas_height();
// Draw semi-transparent purple fill over selected pixels using row spans
let sel_color = egui::Color32::from_rgba_premultiplied(128, 0, 255, 40);
let step = if zoom >= 4.0 {
1
} else if zoom >= 1.0 {
2
} else {
4
};
for cy in (0..h).step_by(step) {
let mut run_start: Option<u32> = None;
for cx in 0..=w {
let selected =
cx < w && mask.get((cy * w + cx) as usize).copied().unwrap_or(0) > 128;
if selected && run_start.is_none() {
run_start = Some(cx);
} else if !selected && run_start.is_some() {
let sx = canvas_rect.min.x + run_start.unwrap() as f32 * zoom;
let ex = canvas_rect.min.x + cx as f32 * zoom;
let sy = canvas_rect.min.y + cy as f32 * zoom;
let ey = sy + (step as f32 * zoom).min(canvas_rect.max.y - sy);
painter.rect_filled(
egui::Rect::from_min_max(egui::pos2(sx, sy), egui::pos2(ex, ey)),
0.0,
sel_color,
);
run_start = None;
}
}
}
// Draw marching ants on edges
let time = ui.input(|i| i.time);
render::draw_selection_overlay(
&mut self.doc.selection_edge_cache,
&painter,
canvas_rect,
mask,
w,
h,
zoom,
time,
);
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(33));
}
}
// Draw selection rect preview while dragging
if let Some(rect) = self.state.selection_rect {
let x1 = rect[0].min(rect[2]);
let y1 = rect[1].min(rect[3]);
let x2 = rect[0].max(rect[2]);
let y2 = rect[1].max(rect[3]);
let r = egui::Rect::from_min_max(
canvas_rect.min + egui::vec2(x1 as f32 * zoom, y1 as f32 * zoom),
canvas_rect.min + egui::vec2(x2 as f32 * zoom, y2 as f32 * zoom),
);
painter.rect_stroke(
r,
0.0,
egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 120, 215)),
egui::StrokeKind::Outside,
);
}
// Draw VisionSelect bbox preview while dragging
if let Some(rect) = self.state.vision_select_rect {
let x1 = rect[0].min(rect[2]);
let y1 = rect[1].min(rect[3]);
let x2 = rect[0].max(rect[2]);
let y2 = rect[1].max(rect[3]);
let r = egui::Rect::from_min_max(
canvas_rect.min + egui::vec2(x1 as f32 * zoom, y1 as f32 * zoom),
canvas_rect.min + egui::vec2(x2 as f32 * zoom, y2 as f32 * zoom),
);
painter.rect_stroke(
r,
0.0,
egui::Stroke::new(1.5, egui::Color32::from_rgb(255, 140, 0)),
egui::StrokeKind::Outside,
);
}
// Draw lasso path while dragging (Lasso tool — continuous drag)
if self.state.is_drawing
&& self.state.active_tool == Tool::Lasso
&& self.state.lasso_points.len() > 1
{
let to_screen = |p: &(u32, u32)| -> egui::Pos2 {
canvas_rect.min + egui::vec2(p.0 as f32 * zoom, p.1 as f32 * zoom)
};
for i in 0..self.state.lasso_points.len() - 1 {
painter.line_segment(
[
to_screen(&self.state.lasso_points[i]),
to_screen(&self.state.lasso_points[i + 1]),
],
egui::Stroke::new(1.5, egui::Color32::WHITE),
);
}
}
// Draw PolygonSelect in-progress polygon (click-by-click vertices)
if self.state.active_tool == Tool::PolygonSelect && self.state.lasso_points.len() > 0 {
let to_screen = |p: &(u32, u32)| -> egui::Pos2 {
canvas_rect.min + egui::vec2(p.0 as f32 * zoom, p.1 as f32 * zoom)
};
// Draw edges between consecutive vertices
for i in 0..self.state.lasso_points.len().saturating_sub(1) {
painter.line_segment(
[
to_screen(&self.state.lasso_points[i]),
to_screen(&self.state.lasso_points[i + 1]),
],
egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 200, 255)),
);
}
// Draw a dotted/closing line from last vertex to cursor (if available)
if let Some(cursor) = self.state.cursor_canvas_pos {
if let Some(&last) = self.state.lasso_points.last() {
painter.line_segment(
[to_screen(&last), to_screen(&cursor)],
egui::Stroke::new(
1.0,
egui::Color32::from_rgba_unmultiplied(0, 200, 255, 100),
),
);
}
}
// Show close-ring indicator near the first vertex (≥3 points only)
if self.state.lasso_points.len() >= 3 {
let first_screen = to_screen(&self.state.lasso_points[0]);
painter.circle_stroke(
first_screen,
10.0 * zoom.min(1.0),
egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 255, 100)),
);
}
// Draw vertex dots
for pt in &self.state.lasso_points {
painter.circle_filled(to_screen(pt), 3.0, egui::Color32::from_rgb(0, 200, 255));
}
}
// ── Transform overlay ────────────────────────────────
if self.state.transform.is_some() {
let ctx = ui.ctx().clone();
if render::draw_transform_overlay(
ui,
canvas_rect,
&self.state,
zoom,
&ctx,
self.transform_texture,
) {
if let Some(tr) = self.state.transform.take() {
crate::canvas::apply_transform_to_layer(&mut self.doc.engine, &tr);
self.state.transform_drag_handle = TransformHandle::None;
self.event_bus.push(AppEvent::RenderRequested);
}
}
}
// ── Vector shape Apply/Cancel overlay ────────────────────
// Shows while a vector shape is being transformed so the user can
// commit (OK) or revert (Cancel) the change. OK also deselects the
// shape so the handles disappear, matching typical vector editor UX.
if self.state.vector_shapes_snapshot.is_some() {
if let Some(idx) = self.state.selected_vector_shape {
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(idx) {
let (x1, y1, x2, y2) = shape.normalized_bounds();
let cx = (x1 + x2) * 0.5;
let cy = (y1 + y2) * 0.5;
let btn_pos = canvas_rect.min
+ egui::vec2(cx * zoom, cy * zoom)
+ egui::vec2(24.0, -24.0);
egui::Area::new(ui.id().with("vec_apply_cancel"))
.fixed_pos(btn_pos)
.order(egui::Order::Foreground)
.show(ui.ctx(), |ui| {
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new("")
.fill(egui::Color32::from_rgb(40, 100, 40)),
)
.clicked()
{
// Commit: clear the snapshot and deselect the shape.
self.state.vector_shapes_snapshot = None;
self.state.selected_vector_shape = None;
self.state.vector_edit_handle = VectorEditHandle::None;
self.event_bus.push(AppEvent::RenderRequested);
}
if ui
.add(
egui::Button::new("")
.fill(egui::Color32::from_rgb(120, 40, 40)),
)
.clicked()
{
let layer_id = self.doc.engine.active_layer_id();
if let Some(snapshot) =
self.state.vector_shapes_snapshot.take()
{
if let Some(shapes) =
self.doc.engine.get_layer_shapes_direct(layer_id)
{
*shapes = snapshot;
self.doc.engine.mark_composite_dirty();
}
}
// Revert: deselect and request a re-render so the
// original shape is visible again.
self.state.selected_vector_shape = None;
self.state.vector_edit_handle = VectorEditHandle::None;
self.event_bus.push(AppEvent::RenderRequested);
}
// Flip H/V buttons for Bubble and Arrow shapes
if let Some(idx) = self.state.selected_vector_shape {
let layer_id = self.doc.engine.active_layer_id();
if let Some(shapes) = self.doc.engine.active_vector_shapes()
{
if let Some(shape) = shapes.get(idx) {
let can_flip = matches!(
shape,
VectorShape::Bubble { .. }
| VectorShape::Arrow { .. }
| VectorShape::Arrow4 { .. }
);
if can_flip {
ui.separator();
if ui
.button("")
.on_hover_text("Flip Horizontal")
.clicked()
{
self.doc.engine.modify_vector_shape(
layer_id,
idx,
VectorEditHandle::None,
-1.0,
0.0,
);
}
if ui
.button("")
.on_hover_text("Flip Vertical")
.clicked()
{
self.doc.engine.modify_vector_shape(
layer_id,
idx,
VectorEditHandle::None,
0.0,
-1.0,
);
}
}
}
}
}
});
});
}
}
}
}
// ── Smart Patch / AI tool overlay ──────────────────────────
if self.state.active_tool.is_ai_tool()
|| self.state.active_tool == Tool::SpotRemoval
|| self.state.active_tool == Tool::RedEyeRemoval
|| self.state.active_tool == Tool::VisionSelect
{
let color = egui::Color32::from_rgba_unmultiplied(255, 50, 255, 120);
if !self.state.smart_patch_points.is_empty() {
let to_screen = |p: &(u32, u32)| -> egui::Pos2 {
canvas_rect.min + egui::vec2(p.0 as f32 * zoom, p.1 as f32 * zoom)
};
let size = self.state.active_size() * zoom;
let skip = (self.state.smart_patch_points.len() / 500).max(1);
for (idx, &p) in self.state.smart_patch_points.iter().enumerate() {
if idx % skip == 0 {
painter.circle_filled(to_screen(&p), size / 2.0, color);
}
}
// Draw outline for lasso-like polygon
if self.state.smart_patch_points.len() > 2 {
for i in 0..self.state.smart_patch_points.len() - 1 {
painter.line_segment(
[
to_screen(&self.state.smart_patch_points[i]),
to_screen(&self.state.smart_patch_points[i + 1]),
],
egui::Stroke::new(2.0, egui::Color32::from_rgb(255, 80, 255)),
);
}
}
}
}
// ── Raster tool cursor preview (crosshair + circle) ──────────
// Only draw the circle+crosshair overlay for raster tools that do NOT
// already have a stamp-texture preview (those are handled by the
// "Brush cursor preview" block above). Tools with shows_pen_tips()
// already get a shaped stamp preview + crosshair there.
if (self.state.active_tool.is_raster_compatible() || self.state.active_tool.is_ai_tool())
&& !self.state.active_tool.shows_pen_tips()
{
if let Some(hover_pos) = ui.input(|i| i.pointer.hover_pos()) {
if canvas_rect.contains(hover_pos) {
if !matches!(self.state.active_tool, Tool::AiObjectRemoval) {
let size = self.state.active_size() * zoom;
let stroke_color = if self.state.is_drawing {
egui::Color32::WHITE.gamma_multiply(0.5)
} else {
egui::Color32::from_gray(200)
};
painter.circle_stroke(
hover_pos,
size / 2.0,
egui::Stroke::new(1.0, stroke_color),
);
painter.line_segment(
[
hover_pos - egui::vec2(6.0, 0.0),
hover_pos + egui::vec2(6.0, 0.0),
],
egui::Stroke::new(1.0, stroke_color),
);
painter.line_segment(
[
hover_pos - egui::vec2(0.0, 6.0),
hover_pos + egui::vec2(0.0, 6.0),
],
egui::Stroke::new(1.0, stroke_color),
);
}
}
}
}
// ── Inline text editor overlay ───────────────────────────────
// Rendered last so it sits on top of the canvas and accepts keystrokes.
if self.state.text_edit.active {
text_overlay::draw_text_overlay(
self.doc,
self.state,
self.event_bus,
ui.ctx(),
&painter,
canvas_rect,
zoom,
);
}
// ── Double-click on canvas to edit a text layer ─────────────
// Detected after overlays so clicks in the inline editor do not
// accidentally trigger a layer re-select.
if response.double_clicked() && is_active_doc {
if let Some(pos) = response.hover_pos() {
if canvas_rect.contains(pos) {
let rel = pos - canvas_rect.min;
let cx = (rel.x / zoom) as f32;
let cy = (rel.y / zoom) as f32;
if let Some(layer_id) = find_text_layer_at(&self.doc.engine, cx, cy) {
self.event_bus
.push(crate::event_bus::AppEvent::TextLayerSelected(layer_id));
}
}
}
}
response.context_menu(|ui| {
ui.set_min_width(180.0);
if ui.button("📋 Copy").clicked() {
self.event_bus.push(AppEvent::ClipboardCopy);
ui.close();
}
if self.state.selection_rect.is_some() {
if ui.button("Cut").clicked() {
self.event_bus.push(AppEvent::ClipboardCut);
ui.close();
}
}
if ui.button("📋 Paste").clicked() {
self.event_bus.push(AppEvent::ClipboardPaste);
ui.close();
}
ui.separator();
if ui.button(" New Layer").clicked() {
self.event_bus.push(AppEvent::LayerAdded);
ui.close();
}
if self.state.selection_rect.is_some() {
if ui.button("🚫 Deselect").clicked() {
self.event_bus.push(AppEvent::SelectionDeselect);
ui.close();
}
if ui.button("Crop Image").clicked() {
self.event_bus.push(AppEvent::Crop);
ui.close();
}
}
ui.separator();
ui.menu_button("✨ Quick Filters", |ui| {
if ui.button("Gaussian Blur (5px)").clicked() {
self.event_bus.push(AppEvent::ApplyFilter(
"gaussian_blur".to_string(),
serde_json::json!({ "radius": 5.0 }),
));
ui.close();
}
if ui.button("Mosaic / Pixelate (8px)").clicked() {
self.event_bus.push(AppEvent::ApplyFilter(
"mosaic".to_string(),
serde_json::json!({ "size": 8 }),
));
ui.close();
}
if ui.button("Unsharp Mask").clicked() {
self.event_bus.push(AppEvent::ApplyFilter(
"unsharp_mask".to_string(),
serde_json::json!({ "amount": 1.5, "radius": 1.0 }),
));
ui.close();
}
});
});
response
}
}
/// Create a VectorShape from a drag operation based on the active tool.
fn create_shape_from_drag(
tool: Tool,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
state: &ToolState,
catalog: &hcie_engine_api::ShapeCatalog,
) -> hcie_engine_api::VectorShape {
use hcie_engine_api::VectorShape;
let color = state.primary_color;
let fill_color = state.secondary_color;
let stroke = state.tool_configs.vector.stroke_size;
let opacity = state.tool_configs.vector.opacity;
let fill = state.tool_configs.vector.fill_shape;
let hardness = state.tool_configs.vector.hardness;
match tool {
Tool::VectorLine => VectorShape::Line {
name: String::new(),
x1,
y1,
x2,
y2,
stroke,
color,
angle: 0.0,
cap_start: hcie_engine_api::LineCap::Round,
cap_end: hcie_engine_api::LineCap::Round,
opacity,
hardness,
},
Tool::VectorRect => VectorShape::Rect {
name: String::new(),
x1,
y1,
x2,
y2,
stroke,
color,
fill_color,
fill,
radius: state.tool_configs.vector.round_rect_radius,
angle: 0.0,
opacity,
hardness,
},
Tool::VectorCircle => VectorShape::Circle {
name: String::new(),
x1,
y1,
x2,
y2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness,
},
Tool::VectorArrow => {
let mut p = std::collections::HashMap::new();
p.insert("thick".to_string(), 0.0);
match hcie_engine_api::create_vector_shape("arrow", x1, y1, x2, y2, &p) {
VectorShape::SvgShape { kind, svg, .. } => {
// Derive the arrow's rotation from the drag direction so
// it points along the line drawn by the user.
let arrow_angle = (y2 - y1).atan2(x2 - x1);
VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: arrow_angle, opacity, hardness,
}
}
_ => unreachable!(),
}
},
Tool::VectorStar => {
let mut p = std::collections::HashMap::new();
p.insert("points".to_string(), state.tool_configs.vector.star_points as f32);
p.insert("inner_radius".to_string(), state.tool_configs.vector.star_inner_radius);
match hcie_engine_api::create_vector_shape("star", x1, y1, x2, y2, &p) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorPolygon => VectorShape::Polygon {
name: String::new(),
x1,
y1,
x2,
y2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness,
sides: state.tool_configs.vector.polygon_sides,
},
Tool::VectorRhombus => {
match hcie_engine_api::create_vector_shape("rhombus", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorCylinder => {
match hcie_engine_api::create_vector_shape("cylinder", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorHeart => {
match hcie_engine_api::create_vector_shape("heart", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorBubble => {
match hcie_engine_api::create_vector_shape("bubble", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorGear => {
match hcie_engine_api::create_vector_shape("gear", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorCross => {
match hcie_engine_api::create_vector_shape("cross", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorCrescent => {
match hcie_engine_api::create_vector_shape("crescent", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorBolt => {
match hcie_engine_api::create_vector_shape("bolt", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::VectorArrow4 => {
match hcie_engine_api::create_vector_shape("arrow4", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
},
_ => unreachable!(),
}
},
Tool::CustomShape(idx) => {
if let Some(entry) = catalog.get(idx) {
VectorShape::SvgShape {
name: String::new(),
x1, y1, x2, y2,
kind: entry.kind.clone(),
svg: entry.svg.clone(),
stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
}
} else {
// Fallback: empty SVG rectangle
VectorShape::SvgShape {
name: String::new(),
x1, y1, x2, y2,
kind: "custom".to_string(),
svg: r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="M 0,0 L 1,0 L 1,1 L 0,1 Z"/></svg>"#.to_string(),
stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
}
}
},
_ => VectorShape::Line {
name: String::new(),
x1,
y1,
x2,
y2,
stroke,
color,
angle: 0.0,
cap_start: hcie_engine_api::LineCap::Round,
cap_end: hcie_engine_api::LineCap::Round,
opacity,
hardness,
},
}
}
/// Draw a realtime preview for the active shape tool while the user drags.
///
/// # Purpose
/// Gives immediate visual feedback of the shape that will be created. The
/// preview is rendered directly by egui on top of the canvas, so it does not
/// depend on the engine compositing pipeline.
///
/// # Logic & Workflow
/// 1. Maps the canvas-space drag rectangle to screen space using the current zoom.
/// 2. For `VectorLine`, draws a line between the two drag corners.
/// 3. For `VectorCircle`, draws an axis-aligned ellipse matching the drag rect.
/// 4. For `VectorStar` and `VectorPolygon`, samples the vertices and draws the
/// outline as connected line segments.
/// 5. For all remaining shape tools, falls back to a stroked rectangle preview.
///
/// # Arguments
/// * `painter` - The egui painter for the canvas widget.
/// * `tool` - The active shape tool.
/// * `canvas_min` - The screen-space top-left corner of the canvas.
/// * `zoom` - Current canvas zoom factor.
/// * `x1`, `y1`, `x2`, `y2` - Canvas-space drag rectangle (already normalized for rect preview).
/// * `stroke` - Common preview stroke style.
/// * `cfg` - Vector tool configuration used for star/polygon parameters.
fn draw_shape_tool_preview(
painter: &egui::Painter,
tool: Tool,
canvas_min: egui::Pos2,
zoom: f32,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
stroke: egui::Stroke,
cfg: &hcie_engine_api::tools::VectorConfig,
) {
let to_screen =
|cx: f32, cy: f32| -> egui::Pos2 { canvas_min + egui::vec2(cx * zoom, cy * zoom) };
let left = x1.min(x2);
let top = y1.min(y2);
let right = x1.max(x2);
let bottom = y1.max(y2);
match tool {
Tool::VectorLine => {
painter.line_segment([to_screen(x1, y1), to_screen(x2, y2)], stroke);
}
Tool::VectorCircle => {
let cx = (left + right) / 2.0;
let cy = (top + bottom) / 2.0;
let rx = (right - left) / 2.0;
let ry = (bottom - top) / 2.0;
// Use enough line segments for a smooth ellipse preview.
let steps = ((rx + ry).max(1.0) * 4.0).ceil() as i32;
let mut pts = Vec::with_capacity(steps as usize + 1);
for i in 0..=steps {
let t = i as f32 / steps as f32 * 2.0 * std::f32::consts::PI;
pts.push(to_screen(cx + rx * t.cos(), cy + ry * t.sin()));
}
painter.add(egui::Shape::line(pts, stroke));
}
Tool::VectorStar => {
let cx = (left + right) / 2.0;
let cy = (top + bottom) / 2.0;
let outer_r = (right - left).min(bottom - top) / 2.0;
let inner_r = outer_r * cfg.star_inner_radius.max(0.1);
let n = cfg.star_points as i32;
if n >= 2 {
let mut pts = Vec::with_capacity((n * 2) as usize);
for i in 0..(n * 2) {
let a = std::f32::consts::PI * 2.0 * i as f32 / (n * 2) as f32
- std::f32::consts::PI / 2.0;
let r = if i % 2 == 0 { outer_r } else { inner_r };
pts.push(to_screen(cx + r * a.cos(), cy + r * a.sin()));
}
painter.add(egui::Shape::closed_line(pts, stroke));
}
}
Tool::VectorPolygon => {
let cx = (left + right) / 2.0;
let cy = (top + bottom) / 2.0;
let r = (right - left).min(bottom - top) / 2.0;
let n = cfg.polygon_sides.max(3) as i32;
let mut pts = Vec::with_capacity(n as usize);
for i in 0..n {
let a =
std::f32::consts::PI * 2.0 * i as f32 / n as f32 - std::f32::consts::PI / 2.0;
pts.push(to_screen(cx + r * a.cos(), cy + r * a.sin()));
}
painter.add(egui::Shape::closed_line(pts, stroke));
}
_ => {
let screen_rect =
egui::Rect::from_min_max(to_screen(left, top), to_screen(right, bottom));
let radius = if tool == Tool::VectorRect {
cfg.round_rect_radius
.min((right - left) * 0.5)
.min((bottom - top) * 0.5)
* zoom
} else {
0.0
};
painter.rect_stroke(screen_rect, radius, stroke, egui::StrokeKind::Outside);
}
}
}
/// ### Purpose
/// Applies a `SelectionTransform` (floating overlay) back to the active layer
/// by writing the transform pixels at the transform position. The original
/// selection area was already cleared when the transform started, so this
/// function only writes the moved pixels and leaves the rest of the layer intact.
///
/// ### Logic & Workflow
/// 1. Reads the current active layer pixels (which already have the original
/// selection area cleared).
/// 2. Writes the transform pixels at `tr.pos` with optional scaling based on
/// `tr.size / original_size`.
///
/// ### Side Effects / Dependencies
/// - Mutates engine layer pixels via `get_active_layer_pixels` and
/// `set_active_layer_pixels`. Does NOT clear the entire layer.
fn apply_transform_to_layer(engine: &mut hcie_engine_api::Engine, tr: &SelectionTransform) {
let canvas_w = engine.canvas_width();
let canvas_h = engine.canvas_height();
let px = tr.pos.x.round() as i32;
let py = tr.pos.y.round() as i32;
// Do NOT clear the entire layer here. The original selection area was already
// cleared when the transform started (in the Tool::Move press handler). Clearing
// the whole layer again would delete pixels outside the selection.
if let Some(mut dest) = engine.get_active_layer_pixels() {
let src_w = tr.width as f32;
let src_h = tr.height as f32;
let scale_x = if src_w > 0.0 { tr.size.x / src_w } else { 1.0 };
let scale_y = if src_h > 0.0 { tr.size.y / src_h } else { 1.0 };
if (scale_x - 1.0).abs() < 0.001 && (scale_y - 1.0).abs() < 0.001 {
for y in 0..tr.height as i32 {
for x in 0..tr.width as i32 {
let dst_x = px + x;
let dst_y = py + y;
if dst_x >= 0
&& dst_x < canvas_w as i32
&& dst_y >= 0
&& dst_y < canvas_h as i32
{
let src_idx = (y as u32 * tr.width + x as u32) as usize * 4;
if src_idx + 3 < tr.pixels.len() && tr.pixels[src_idx + 3] > 0 {
let dst_idx = (dst_y as u32 * canvas_w + dst_x as u32) as usize * 4;
if dst_idx + 3 < dest.len() {
dest[dst_idx..dst_idx + 4]
.copy_from_slice(&tr.pixels[src_idx..src_idx + 4]);
}
}
}
}
}
} else {
let dst_w = tr.size.x.round() as i32;
let dst_h = tr.size.y.round() as i32;
for dy in 0..dst_h {
for dx in 0..dst_w {
let dst_x = px + dx;
let dst_y = py + dy;
if dst_x >= 0
&& dst_x < canvas_w as i32
&& dst_y >= 0
&& dst_y < canvas_h as i32
{
let sx = (dx as f32 / scale_x) as u32;
let sy = (dy as f32 / scale_y) as u32;
if sx < tr.width && sy < tr.height {
let src_idx = (sy * tr.width + sx) as usize * 4;
if src_idx + 3 < tr.pixels.len() && tr.pixels[src_idx + 3] > 0 {
let dst_idx = (dst_y as u32 * canvas_w + dst_x as u32) as usize * 4;
if dst_idx + 3 < dest.len() {
dest[dst_idx..dst_idx + 4]
.copy_from_slice(&tr.pixels[src_idx..src_idx + 4]);
}
}
}
}
}
}
}
engine.set_active_layer_pixels(dest);
}
}
/// Find the topmost text layer whose rasterized pixels contain the point
/// `(x, y)` in canvas coordinates.
///
/// Iterates layers from top (highest engine index) to bottom so clicking
/// stacked text layers selects the visible one first. For each Text layer the
/// point-in-pixel test uses `engine.get_pixel` with an alpha threshold of 10
/// so fully transparent gaps between glyphs do not count as hits.
///
/// # Arguments
/// * `engine` - Engine exposing layer and pixel queries.
/// * `x`, `y` - Canvas-space point to test.
///
/// # Returns
/// The matching text layer id, or `None` if the point is over empty canvas.
fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<u64> {
let ux = x.round().clamp(0.0, u32::MAX as f32) as u32;
let uy = y.round().clamp(0.0, u32::MAX as f32) as u32;
let infos = engine.layer_infos();
// layer_infos returns bottom-to-top; iterate in reverse for topmost first.
for info in infos.iter().rev() {
if info.layer_type != hcie_engine_api::LayerType::Text {
continue;
}
if !info.visible {
continue;
}
if let Some(px) = engine.get_pixel(info.id, ux, uy) {
if px[3] > 10 {
return Some(info.id);
}
}
}
None
}