//! Core Iced application state and update logic. //! //! Implements the Elm-architecture pattern for the HCIE image editor. //! Manages per-document engine instances, tool state, compositing, //! dock layout, keyboard shortcuts, and all UI interactions. //! //! ⚠️ PERFORMANCE-CRITICAL SECTIONS (DO NOT MODIFY): //! - `refresh_composite_if_needed()` — uses engine's tiled compositing + dirty-region partial copy //! - `IcedDocument.composite_raw` — mutable Vec for partial-copy compositing //! - `IcedDocument.composite_handle_cache` — GPU `ImageHandle` reused when `render_generation` unchanged //! - `CanvasZoom` handler — zoom-toward-cursor math //! These sections are optimized for 4K performance. Modifying them //! may cause rendering regressions or performance degradation. use crate::dialogs; use crate::dock::state::{DockState, PaneType}; use crate::io::tablet::TabletState; use crate::panels; use crate::theme::ThemeState; use hcie_engine_api::{BlendMode, BrushTip, BrushStyle, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN}; use iced::widget::{column, container, text}; use iced::{Element, Length, Task, Theme, Vector}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; /// Returns the path to the HCIE config directory (~/.config/hcie-iced/). /// Creates the directory if it doesn't exist. fn config_dir() -> PathBuf { let dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("hcie-iced"); let _ = std::fs::create_dir_all(&dir); dir } /// Returns the path to the recent files JSON config. fn recent_files_path() -> PathBuf { config_dir().join("recent_files.json") } /// Load recent files from disk. fn load_recent_files() -> Vec { let path = recent_files_path(); match std::fs::read_to_string(&path) { Ok(json) => serde_json::from_str(&json).unwrap_or_default(), Err(_) => Vec::new(), } } /// Save recent files to disk. fn save_recent_files(files: &[RecentFileEntry]) { let path = recent_files_path(); if let Ok(json) = serde_json::to_string_pretty(files) { let _ = std::fs::write(&path, json); } } /// Active dialog type. #[derive(Debug, Clone, PartialEq)] #[allow(dead_code)] pub enum ActiveDialog { None, NewImage, BrightnessContrast, HueSaturation, CloseConfirm, SelectionOp(&'static str), LayerStyleDialog, } /// Top-level application state. pub struct HcieIcedApp { /// All open documents. pub documents: Vec, /// Index of the currently active document. pub active_doc: usize, /// Current tool state. pub tool_state: ToolState, /// Foreground drawing color (RGBA). pub fg_color: [u8; 4], /// Background drawing color (RGBA). pub bg_color: [u8; 4], /// Last known cursor position from mouse_area events. pub last_cursor_pos: Option<(f32, f32)>, /// Cursor position in canvas-space for status bar display. pub canvas_cursor_pos: Option<(u32, u32)>, /// Currently active dialog (if any). pub active_dialog: ActiveDialog, /// Window ID for window control operations (drag, minimize, maximize, close). pub window_id: Option, /// New Image dialog state. pub dialog_new_name: String, pub dialog_new_width: u32, pub dialog_new_height: u32, pub dialog_new_transparent: bool, /// Adjustment dialog state. pub dialog_brightness: f32, pub dialog_contrast: f32, pub dialog_hue: f32, pub dialog_saturation: f32, pub dialog_lightness: f32, /// Selection operation dialog state. pub dialog_selection_value: f32, /// Selected filter. pub selected_filter: Option, /// Filter parameters (JSON) for the currently selected filter. pub filter_params: serde_json::Value, /// Filter preview state. pub filter_preview_active: bool, /// Dock layout state. pub dock: DockState, /// Shared tablet state for pressure input. pub tablet_state: Arc>, /// Whether the app has been initialized (load_path processed). pub initialized: bool, /// Theme state for consistent styling. pub theme_state: ThemeState, /// Currently open menu index (None = no menu open). pub active_menu: Option, /// Recently opened files (most recent first, max 20). pub recent_files: Vec, /// Recent colors used (most recent first, max 40). pub recent_colors: Vec<[u8; 4]>, /// Selected style index in Layer Styles panel. pub selected_style: usize, /// Whether the color picker popup is open for a layer style. pub show_style_color_picker: bool, /// Index of the style whose color picker is open. pub style_color_picker_idx: usize, /// HSL color state for the style color picker (hue 0..360, sat 0..1, light 0..1). pub style_color_hsl: (f32, f32, f32), /// Currently active color picker tab (0=Wheel, 1=Sliders, 2=Grid). pub color_tab: usize, /// Layer Style dialog drag offset from center (x, y) in pixels. pub layer_style_offset: (f32, f32), /// Whether the Layer Style dialog is being dragged. pub layer_style_dragging: bool, /// Last drag position for delta calculation. pub layer_style_drag_start: Option<(f32, f32)>, } /// A recently opened file entry. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RecentFileEntry { pub path: String, pub name: String, } /// Per-document state wrapping an engine instance. pub struct IcedDocument { pub engine: Engine, /// Composite RGBA pixel data shared with the shader pipeline via Arc. /// The shader's `prepare()` reads this and uploads only the dirty region /// via `queue.write_texture()`. Updated in `refresh_composite_if_needed()`. pub composite_pixels: Arc>, /// Mutable composite buffer for partial-copy from engine. /// After updating, this is wrapped in a new Arc for the shader. composite_raw: Vec, /// Document display name. pub name: String, /// Original file path, if loaded from disk. pub source_path: Option, /// Whether the document has unsaved changes. pub modified: bool, /// Per-document zoom level. pub zoom: f32, /// Per-document pan offset in screen pixels. pub pan_offset: Vector, /// Cached layer infos for the layers panel. pub cached_layers: Vec, /// Cached history items for the history panel. pub cached_history: Vec<(usize, String)>, /// Current history index. pub history_current: i32, /// Selection rectangle in canvas coordinates (for select tool). pub selection_rect: Option<(f32, f32, f32, f32)>, /// Vector shape being drawn (start pos, current pos). pub vector_draw: Option<((f32, f32), (f32, f32))>, /// Canvas pane size (width, height) in pixels — updated by the view. pub pane_size: (f32, f32), /// Render generation counter; incremented whenever the engine composite /// buffer is refreshed. pub render_generation: u64, /// Dirty region from the last engine composite [x0, y0, x1, y1]. /// Set by `refresh_composite_if_needed()`, consumed by the shader. pub dirty_region: std::cell::RefCell>, /// Whether a full texture upload is needed (first frame, resize, file load). /// Set to true after loading a file or changing canvas dimensions. pub full_upload: std::cell::RefCell, } /// Drawing tool state. pub struct ToolState { /// Currently selected tool. pub active_tool: Tool, /// Whether the user is currently drawing (mouse button held). pub is_drawing: bool, /// Canvas-space coordinates of the last pointer event during a stroke. pub last_stroke_pos: Option<(f32, f32)>, /// Accumulated distance for the current stroke (for pressure interpolation). pub brush_accumulated_dist: f32, /// Current brush size. pub brush_size: f32, /// Current brush opacity. pub brush_opacity: f32, /// Current brush hardness. pub brush_hardness: f32, /// Current brush style. pub brush_style: BrushStyle, /// Current brush spacing. pub brush_spacing: f32, /// Timestamp of the last composite refresh for throttling during strokes. pub last_composite_refresh: std::time::Instant, /// Timestamp of the last update() call for frame timing. pub last_update_instant: std::time::Instant, } impl Default for ToolState { fn default() -> Self { Self { active_tool: Tool::Brush, is_drawing: false, last_stroke_pos: None, brush_accumulated_dist: 0.0, brush_size: 20.0, brush_opacity: 1.0, brush_hardness: 0.8, brush_style: BrushStyle::Round, brush_spacing: 1.0, last_composite_refresh: std::time::Instant::now(), last_update_instant: std::time::Instant::now(), } } } /// Messages the application handles. #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Message { // ── Tool selection ────────────────────────────────── ToolSelected(Tool), // ── Color ─────────────────────────────────────────── FgColorChanged([u8; 4]), BgColorChanged([u8; 4]), SwapColors, ColorTabChanged(usize), // ── Canvas interaction ────────────────────────────── CanvasPointerPressed { x: f32, y: f32 }, CanvasPointerMoved { x: f32, y: f32 }, CanvasPointerReleased, CanvasPanZoom { zoom: f32, pan_offset: Vector }, CanvasZoomSet(f32), CanvasZoomRelative(f32), CanvasSize((f32, f32)), CanvasCursorPos { x: u32, y: u32 }, // ── Engine operations ─────────────────────────────── CompositeRefresh, Undo, Redo, // ── Layer operations ──────────────────────────────── LayerSelect(u64), LayerAdd, LayerDelete(u64), LayerToggleVisibility(u64), LayerToggleLock(u64), LayerSetOpacity(u64, f32), LayerMergeDown, LayerFlatten, LayerMoveUp(u64), LayerMoveDown(u64), LayerSetBlendMode(u64, BlendMode), LayerSetFillOpacity(u64, f32), LayerAddGroup, LayerToggleCollapse(u64), // ── History ───────────────────────────────────────── HistoryJumpTo(i32), // ── Text tool ─────────────────────────────────────── TextSizeChanged(f32), TextColorChanged([u8; 4]), TextAlignChanged(String), TextAccept, TextCancel, // ── Layer styles ──────────────────────────────────── LayerStyleToggle(u32), LayerStyleSelect(usize), LayerStyleApply, LayerStyleCancel, LayerStyleUpdateParam(usize, String, f32), LayerStyleUpdateBlendMode(usize, String), LayerStyleUpdateColor(usize, [u8; 4]), ShowStyleColorPicker(usize), HideStyleColorPicker, OpenLayerStyleDialog, LayerStyleDialogDragStart(f32, f32), LayerStyleDialogDragMove(f32, f32), LayerStyleDialogDragEnd, LayerStyleAdd, // ── Brushes ───────────────────────────────────────── BrushStyleSelected(BrushStyle), BrushSizeChanged(f32), BrushOpacityChanged(f32), BrushHardnessChanged(f32), // ── Filters ───────────────────────────────────────── FilterSelect(FilterType), FilterApply, FilterReset, FilterParamChanged(String, serde_json::Value), // ── Dialogs ───────────────────────────────────────── DialogOpen(ActiveDialog), DialogClose, DialogCloseSave, DialogCloseDiscard, // ── New Image Dialog ──────────────────────────────── DialogNewImageName(String), DialogNewImageWidth(u32), DialogNewImageHeight(u32), DialogNewImageTransparent(bool), DialogNewImagePreset(u32, u32), DialogNewImageCreate, // ── Adjustments Dialog ────────────────────────────── AdjBrightness(f32), AdjContrast(f32), AdjHue(f32), AdjSaturation(f32), AdjLightness(f32), AdjApply, // ── Selection Dialog ──────────────────────────────── SelectionOpValue(f32), SelectionOpApply, // ── Selection tools ───────────────────────────────── SelectionDragStart(f32, f32), SelectionDragMove(f32, f32), SelectionDragEnd, // ── Vector tools ──────────────────────────────────── VectorDrawStart(f32, f32), VectorDrawMove(f32, f32), VectorDrawEnd, // ── AI Chat ───────────────────────────────────────── AiChatInput(String), AiChatSend, AiChatClear, // ── Clipboard ─────────────────────────────────────── CopyImage, PasteImage, CopyText(String), PasteText, ClipboardResult(Result, String>), ImageCopied(Result<(), String>), ImagePasted(Result, u32, u32)>, String>), // ── File I/O (with rfd) ───────────────────────────── OpenFileRfd, SaveFileAs, FileDialogClosed(Option), // ── Dock Layout ───────────────────────────────────── PaneClicked(iced::widget::pane_grid::Pane), PaneDragged(iced::widget::pane_grid::DragEvent), PaneResized(iced::widget::pane_grid::ResizeEvent), PaneClose(iced::widget::pane_grid::Pane), PaneMaximize(iced::widget::pane_grid::Pane), // ── File I/O ──────────────────────────────────────── NewDocument(u32, u32), DocSwitch(usize), OpenFile, FileOpened(Result), SaveFile, // ── Menu ───────────────────────────────────────────── MenuOpen(usize), MenuClose, MenuAction(usize, usize), // (menu_index, item_index) OpenRecentFile(usize), // index into recent_files ClearRecentFiles, // ── Window ─────────────────────────────────────────── WindowDrag, WindowMinimize, WindowMaximize, WindowClose, WindowId(iced::window::Id), // ── Misc ──────────────────────────────────────────── NoOp, } /// Convert a FilterType enum to the engine's string filter ID. fn filter_type_to_id(filter: FilterType) -> &'static str { match filter { FilterType::BoxBlur => "box_blur", FilterType::GaussianBlur => "gaussian_blur", FilterType::MotionBlur => "motion_blur", FilterType::UnsharpMask => "sharpen", FilterType::Mosaic => "mosaic", FilterType::Pinch => "pinch", FilterType::Twirl => "twirl", FilterType::OilPaint => "oil_paint", FilterType::Crystallize => "crystallize", FilterType::Levels => "levels", FilterType::Vibrance => "vibrance", FilterType::Exposure => "exposure", FilterType::Posterize => "posterize", FilterType::Threshold => "threshold", FilterType::ChannelMixer => "channel_mixer", FilterType::BlackAndWhite => "black_and_white", FilterType::SelectiveColor => "selective_color", FilterType::GammaCorrection => "gamma_correction", FilterType::ExtractChannel => "extract_channel", FilterType::GradientMap => "gradient_map", FilterType::GaussianBlurGamma => "gaussian_blur_gamma", FilterType::BoxBlurGamma => "box_blur_gamma", FilterType::MedianFilter => "median_filter", FilterType::Dehaze => "dehaze", FilterType::NoisePattern => "noise_pattern", } } /// Build a BrushTip from the current tool state. /// /// Converts the tool state's brush parameters into an engine BrushTip /// that can be passed to `engine.set_brush_tip()` before starting a stroke. fn build_brush_tip(state: &ToolState) -> BrushTip { BrushTip { style: state.brush_style, size: state.brush_size, opacity: state.brush_opacity, hardness: state.brush_hardness, spacing: state.brush_spacing, ..BrushTip::default() } } /// Helper to combine two bounding boxes of dirty regions. fn union_regions(r1: Option<[u32; 4]>, r2: [u32; 4]) -> [u32; 4] { match r1 { Some(r) => [ r[0].min(r2[0]), r[1].min(r2[1]), r[2].max(r2[2]), r[3].max(r2[3]), ], None => r2, } } impl HcieIcedApp { /// Create the initial application state. /// /// If `load_path` is provided, the file will be opened after initialization. /// Returns a Task to capture the window ID on startup. pub fn new(load_path: Option) -> (Self, Task) { let mut engine = Engine::new(800, 600); engine.pre_tile_all_layers(); let composite_raw = engine.get_composite_pixels(); let cached_layers = engine.layer_infos(); let history_len = engine.history_len(); let cached_history: Vec<(usize, String)> = (0..history_len) .filter_map(|i| engine.history_description(i).map(|d| (i, d))) .collect(); let history_current = engine.history_current(); let doc = IcedDocument { engine, composite_pixels: Arc::new(composite_raw.clone()), composite_raw, name: "Untitled".to_string(), source_path: None, modified: false, zoom: 1.0, pan_offset: Vector::ZERO, cached_layers, cached_history, history_current, selection_rect: None, vector_draw: None, pane_size: (800.0, 600.0), render_generation: 0, dirty_region: std::cell::RefCell::new(None), full_upload: std::cell::RefCell::new(true), }; let mut app = Self { documents: vec![doc], active_doc: 0, tool_state: ToolState::default(), fg_color: [220, 50, 50, 255], bg_color: [255, 255, 255, 255], last_cursor_pos: None, canvas_cursor_pos: None, active_dialog: ActiveDialog::None, window_id: None, dialog_new_name: "Untitled".to_string(), dialog_new_width: 800, dialog_new_height: 600, dialog_new_transparent: true, dialog_brightness: 0.0, dialog_contrast: 0.0, dialog_hue: 0.0, dialog_saturation: 0.0, dialog_lightness: 0.0, dialog_selection_value: 5.0, selected_filter: None, filter_params: serde_json::json!({}), filter_preview_active: false, dock: DockState::new(), tablet_state: Arc::new(Mutex::new(TabletState::new())), initialized: false, theme_state: ThemeState::new(), active_menu: None, recent_files: load_recent_files(), recent_colors: Vec::new(), selected_style: 0, show_style_color_picker: false, style_color_picker_idx: 0, style_color_hsl: (0.0, 0.0, 0.0), color_tab: 0, layer_style_offset: (0.0, 0.0), layer_style_dragging: false, layer_style_drag_start: None, }; // If a file path was provided, open it (route multi-layer formats to dedicated importers) if let Some(path) = load_path { let path_str = path.to_string_lossy().to_string(); let ext = path.extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let result = match ext.as_str() { "psd" => app.documents[0].engine.import_psd(&path_str), "kra" => app.documents[0].engine.import_kra(&path_str), "hcie" => app.documents[0].engine.load_native(&path_str), _ => app.documents[0].engine.open_image(&path_str), }; match result { Ok(()) => { app.documents[0].engine.pre_tile_all_layers(); app.documents[0].name = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled".to_string()); app.documents[0].source_path = Some(path.clone()); app.add_recent_file(&path); // Get the composite via the safe Vec-returning path. // This calls get_composite_pixels() which internally uses // composite_layers() — a pure-Rust function that returns Vec. // This is safe and avoids the raw-pointer segfault in // render_composite_region. let composite = app.documents[0].engine.get_composite_pixels(); let size = composite.len(); log::info!("[startup] composite pixels: {} bytes ({}x{})", size, app.documents[0].engine.canvas_width(), app.documents[0].engine.canvas_height()); app.documents[0].composite_raw = composite; app.documents[0].composite_pixels = std::sync::Arc::new(app.documents[0].composite_raw.clone()); app.documents[0].full_upload.replace(true); app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1); // Refresh cached layer list and history for panels app.documents[0].cached_layers = app.documents[0].engine.layer_infos(); let history_len = app.documents[0].engine.history_len(); app.documents[0].cached_history = (0..history_len) .filter_map(|i| app.documents[0].engine.history_description(i).map(|d| (i, d))) .collect(); app.documents[0].history_current = app.documents[0].engine.history_current(); } Err(e) => { log::error!("Failed to open file on startup: {}", e); } } } app.initialized = true; let init_task = iced::window::get_oldest().map(|opt_id| { if let Some(id) = opt_id { Message::WindowId(id) } else { Message::NoOp } }); (app, init_task) } /// Return a mutable reference to the active document. fn active_document_mut(&mut self) -> &mut IcedDocument { self.documents.get_mut(self.active_doc).expect("no active document") } /// Convert pane-relative coordinates to canvas-space coordinates. /// /// The canvas image is centered in the pane, with `pan_offset` as an /// additional offset from center. This function accounts for both. fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> { let doc = &self.documents[self.active_doc]; let zoom = doc.zoom; let (pane_w, pane_h) = doc.pane_size; let engine_w = doc.engine.canvas_width() as f32; let engine_h = doc.engine.canvas_height() as f32; let display_w = engine_w * zoom; let display_h = engine_h * zoom; // Canvas image position = center of pane + pan_offset let canvas_origin_x = (pane_w - display_w) / 2.0 + doc.pan_offset.x; let canvas_origin_y = (pane_h - display_h) / 2.0 + doc.pan_offset.y; // Convert from pane-space to canvas-space let canvas_x = (sx - canvas_origin_x) / zoom; let canvas_y = (sy - canvas_origin_y) / zoom; if canvas_x >= 0.0 && canvas_x < engine_w && canvas_y >= 0.0 && canvas_y < engine_h { Some((canvas_x, canvas_y)) } else { None } } /// Add a file to the recent files list (most recent first, max 20) and persist to disk. fn add_recent_file(&mut self, path: &std::path::Path) { let path_str = path.to_string_lossy().to_string(); let name = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path_str.clone()); // Remove duplicates self.recent_files.retain(|e| e.path != path_str); // Insert at front self.recent_files.insert(0, RecentFileEntry { path: path_str, name }); // Cap at 20 self.recent_files.truncate(20); // Persist to disk save_recent_files(&self.recent_files); } /// Add a color to the recent colors list (most recent first, max 40). fn add_recent_color(&mut self, color: [u8; 4]) { self.recent_colors.retain(|c| *c != color); self.recent_colors.insert(0, color); self.recent_colors.truncate(40); } /// Refresh the composite buffer using incremental dirty-region compositing. /// /// Uses `render_composite_region()` which only re-composites dirty tiles. /// Only the dirty region is copied from the engine's scratch buffer into /// the local composite buffer, avoiding a full 33 MB copy for small strokes. fn refresh_composite_if_needed(&mut self) { let doc = &mut self.documents[self.active_doc]; if doc.engine.is_composite_dirty() { // Use get_composite_pixels() instead of render_composite_region() // to avoid segfaults when canvas dimensions change (e.g. after PSD import). // get_composite_pixels() returns a safe Vec via composite_layers(). let composite = doc.engine.get_composite_pixels(); let size = composite.len(); if size > 0 { doc.composite_raw = composite; // Update the shared Arc — if no other reference exists, mutate in-place if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) { pixels.resize(doc.composite_raw.len(), 0); pixels.copy_from_slice(&doc.composite_raw); } else { doc.composite_pixels = Arc::new(doc.composite_raw.clone()); } doc.full_upload.replace(true); doc.dirty_region.replace(None); doc.render_generation = doc.render_generation.wrapping_add(1); } doc.engine.clear_dirty_flags(); } // Always refresh cached panel data (cheap operation) doc.cached_layers = doc.engine.layer_infos(); let history_len = doc.engine.history_len(); doc.cached_history = (0..history_len) .filter_map(|i| doc.engine.history_description(i).map(|d| (i, d))) .collect(); doc.history_current = doc.engine.history_current(); } /// Apply brush parameters from tool state to the engine. /// /// Called before starting a stroke to ensure the engine uses /// the current brush size, opacity, hardness, and style. fn apply_brush_params(&mut self) { let tip = build_brush_tip(&self.tool_state); self.documents[self.active_doc].engine.set_brush_tip(tip); } // ── Update ────────────────────────────────────────── /// Handle a message and return an optional command. pub fn update(&mut self, message: Message) -> Task { let frame_start = std::time::Instant::now(); let _since_last = frame_start.duration_since(self.tool_state.last_update_instant); self.tool_state.last_update_instant = frame_start; // Performance log for tracking inter-frame delays (time between Elm update calls). // Helps debug event loop bottlenecks, main thread blockages, or input lag. // if since_last.as_secs_f64() > 0.001 { // log::info!("[perf] inter-frame: {:.1}ms", since_last.as_secs_f64() * 1000.0); // } match message { Message::ToolSelected(tool) => { log::info!("Tool selected: {:?}", tool); self.tool_state.active_tool = tool; } Message::FgColorChanged(color) => { self.fg_color = color; self.active_document_mut().engine.set_color(color); } Message::BgColorChanged(color) => { self.bg_color = color; } Message::SwapColors => { std::mem::swap(&mut self.fg_color, &mut self.bg_color); let color = self.fg_color; self.active_document_mut().engine.set_color(color); } Message::ColorTabChanged(tab) => { self.color_tab = tab; } Message::CanvasPointerPressed { x, y } => { // Coordinates are already in canvas-space from the canvas widget let canvas_x = x; let canvas_y = y; self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32)); let tool = self.tool_state.active_tool; match tool { Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => { // Apply brush parameters before starting stroke self.apply_brush_params(); let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.set_tool(tool); self.documents[self.active_doc].engine.set_eraser(tool == Tool::Eraser); self.documents[self.active_doc].engine.begin_stroke(layer_id, canvas_x, canvas_y); self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, 1.0); self.tool_state.is_drawing = true; self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y)); self.tool_state.brush_accumulated_dist = 0.0; self.tool_state.last_composite_refresh = std::time::Instant::now(); self.refresh_composite_if_needed(); } Tool::Eyedropper => { let cx = canvas_x as u32; let cy = canvas_y as u32; let w = self.documents[self.active_doc].engine.canvas_width(); let h = self.documents[self.active_doc].engine.canvas_height(); if cx < w && cy < h { let pixels = &self.documents[self.active_doc].composite_raw; let idx = ((cy * w + cx) * 4) as usize; if idx + 3 < pixels.len() { let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]]; self.fg_color = color; self.documents[self.active_doc].engine.set_color(color); } } } Tool::FloodFill => { let cx = canvas_x as u32; let cy = canvas_y as u32; let color = self.fg_color; self.documents[self.active_doc].engine.flood_fill(cx, cy, color, 32); self.refresh_composite_if_needed(); } Tool::Select => { // Start selection rectangle drag let sx = canvas_x; let sy = canvas_y; return Task::perform(async move {}, move |_| { Message::SelectionDragStart(sx, sy) }); } Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine => { // Start vector shape drawing let sx = canvas_x; let sy = canvas_y; return Task::perform(async move {}, move |_| { Message::VectorDrawStart(sx, sy) }); } _ => {} } } Message::CanvasPointerMoved { x, y } => { // Coordinates are already in canvas-space from the canvas widget self.canvas_cursor_pos = Some((x as u32, y as u32)); if self.tool_state.is_drawing { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let spacing: f32 = match self.tool_state.active_tool { Tool::Spray => 2.0_f32, _ => 1.0_f32, }.max(1.0); if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { let dx = x - last_x; let dy = y - last_y; let dist = (dx * dx + dy * dy).sqrt(); // Use constant pressure for mouse input // Real tablet pressure comes from tablet_state let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0); if dist >= spacing { self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure); self.tool_state.last_stroke_pos = Some((x, y)); // Throttle composite refresh to ~60 FPS during strokes. let now = std::time::Instant::now(); let elapsed = now.duration_since(self.tool_state.last_composite_refresh); if elapsed.as_secs_f32() >= 0.016 { self.tool_state.last_composite_refresh = now; // Performance log for measuring drawing throttle frequency (should fire roughly every 16ms / 60 FPS). // log::info!("[perf] throttle fire: {:.1}ms since last refresh", elapsed.as_secs_f64() * 1000.0); self.refresh_composite_if_needed(); } } } else { self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0); self.tool_state.last_stroke_pos = Some((x, y)); } } // Handle selection drag if self.tool_state.active_tool == Tool::Select { let mx = x; let my = y; return Task::perform(async move {}, move |_| { Message::SelectionDragMove(mx, my) }); } // Handle vector draw drag if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) { let mx = x; let my = y; return Task::perform(async move {}, move |_| { Message::VectorDrawMove(mx, my) }); } } Message::CanvasPointerReleased => { if self.tool_state.is_drawing { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.end_stroke(layer_id); self.documents[self.active_doc].engine.commit_pending_history(); self.tool_state.is_drawing = false; self.tool_state.last_stroke_pos = None; self.refresh_composite_if_needed(); } // End selection drag if self.tool_state.active_tool == Tool::Select { return Task::perform(async {}, |_| Message::SelectionDragEnd); } // End vector draw if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) { return Task::perform(async {}, |_| Message::VectorDrawEnd); } } Message::CanvasPanZoom { zoom, pan_offset } => { let doc = &mut self.documents[self.active_doc]; doc.zoom = zoom; doc.pan_offset = pan_offset; } Message::CanvasCursorPos { x, y } => { self.canvas_cursor_pos = Some((x, y)); } Message::CanvasZoomSet(z) => { let doc = &mut self.documents[self.active_doc]; doc.zoom = z.clamp(ZOOM_MIN, ZOOM_MAX); // Keep pan_offset stable so the visible content does not jump // when zooming via keyboard/menu/slider. The canvas view // centers on pane+pan_offset, so holding pan_offset keeps the // same logical content anchored. } Message::CanvasZoomRelative(factor) => { let doc = &mut self.documents[self.active_doc]; doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX); // Keep pan_offset — zooming relative to center via keyboard. } Message::CanvasSize((w, h)) => { self.documents[self.active_doc].pane_size = (w, h); // Keep pan_offset so the canvas stays anchored when the pane // is resized (e.g. dock splitter dragged). } Message::CompositeRefresh | Message::Undo | Message::Redo => { match message { Message::Undo => { self.documents[self.active_doc].engine.undo(); } Message::Redo => { self.documents[self.active_doc].engine.redo(); } _ => {} } self.refresh_composite_if_needed(); } // ── Layer operations ────────────────────────── Message::LayerSelect(id) => { self.documents[self.active_doc].engine.set_active_layer(id); } Message::LayerAdd => { let count = self.documents[self.active_doc].engine.get_layer_count(); self.documents[self.active_doc].engine.add_layer(&format!("Layer {}", count + 1)); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerDelete(id) => { self.documents[self.active_doc].engine.delete_layer(id); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerToggleVisibility(id) => { let visible = self.documents[self.active_doc].engine.get_layer_info(id) .map(|l| !l.visible) .unwrap_or(true); self.documents[self.active_doc].engine.set_layer_visible(id, visible); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerToggleLock(id) => { let locked = self.documents[self.active_doc].engine.get_layer_info(id) .map(|l| !l.locked) .unwrap_or(false); self.documents[self.active_doc].engine.set_layer_locked(id, locked); } Message::LayerSetOpacity(id, opacity) => { self.documents[self.active_doc].engine.set_layer_opacity(id, opacity); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerMergeDown => { self.documents[self.active_doc].engine.merge_down(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerFlatten => { self.documents[self.active_doc].engine.flatten_image(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerMoveUp(id) => { let infos = self.documents[self.active_doc].engine.layer_infos(); if let Some(pos) = infos.iter().position(|l| l.id == id) { if pos > 0 { let target_idx = pos - 1; self.documents[self.active_doc].engine.move_layer(id, target_idx); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } } Message::LayerMoveDown(id) => { let infos = self.documents[self.active_doc].engine.layer_infos(); if let Some(pos) = infos.iter().position(|l| l.id == id) { if pos + 1 < infos.len() { let target_idx = pos + 1; self.documents[self.active_doc].engine.move_layer(id, target_idx); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } } Message::LayerSetBlendMode(id, mode) => { self.documents[self.active_doc].engine.set_layer_blend_mode(id, mode); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerSetFillOpacity(_id, _opacity) => { // Fill opacity is not yet exposed via engine API; placeholder for future use. } Message::LayerAddGroup => { let count = self.documents[self.active_doc].engine.get_layer_count(); self.documents[self.active_doc].engine.add_group(&format!("Group {}", count + 1)); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerToggleCollapse(id) => { self.documents[self.active_doc].engine.toggle_group_collapsed(id); } // ── History ─────────────────────────────────── Message::HistoryJumpTo(idx) => { self.documents[self.active_doc].engine.jump_to_history(idx); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } // ── Text tool ───────────────────────────────── Message::TextSizeChanged(_size) => { // TODO: apply to active text tool config } Message::TextColorChanged(_color) => { // TODO: apply to active text tool config } Message::TextAlignChanged(_alignment) => { // TODO: apply to active text tool config } Message::TextAccept => { // TODO: commit text changes } Message::TextCancel => { // TODO: cancel text changes } // ── Layer styles ────────────────────────────── Message::LayerStyleToggle(index) => { // Toggle layer style enabled state. // BUG FIX: When style doesn't exist, first click creates it ENABLED (not disabled). // Previous logic used map_or(false, ...) which created disabled styles on first click. let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; if (index as usize) < disc_names.len() { let disc = disc_names[index as usize]; // Find existing style let existing = styles.iter().find(|s| { matches!((disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) | ("InnerShadow", LayerStyle::InnerShadow { .. }) | ("OuterGlow", LayerStyle::OuterGlow { .. }) | ("InnerGlow", LayerStyle::InnerGlow { .. }) | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) | ("Satin", LayerStyle::Satin { .. }) | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) | ("Stroke", LayerStyle::Stroke { .. }) ) }); // Determine new enabled state: // - Style doesn't exist → first click enables it (true) // - Style exists → toggle current state let new_enabled = match existing { None => true, // First click: create ENABLED Some(s) => { let was_enabled = match s { LayerStyle::DropShadow { enabled, .. } | LayerStyle::InnerShadow { enabled, .. } | LayerStyle::OuterGlow { enabled, .. } | LayerStyle::InnerGlow { enabled, .. } | LayerStyle::BevelEmboss { enabled, .. } | LayerStyle::Satin { enabled, .. } | LayerStyle::ColorOverlay { enabled, .. } | LayerStyle::GradientOverlay { enabled, .. } | LayerStyle::PatternOverlay { enabled, .. } | LayerStyle::Stroke { enabled, .. } => *enabled, }; !was_enabled // Toggle } }; let new_style = match disc { "DropShadow" => LayerStyle::DropShadow { enabled: new_enabled, opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0, color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(), }, "InnerShadow" => LayerStyle::InnerShadow { enabled: new_enabled, opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0, color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(), }, "OuterGlow" => LayerStyle::OuterGlow { enabled: new_enabled, opacity: 0.75, spread: 0.0, size: 5.0, color: [255, 255, 190, 255], blend_mode: "Screen".to_string(), }, "InnerGlow" => LayerStyle::InnerGlow { enabled: new_enabled, opacity: 0.75, spread: 0.0, size: 5.0, color: [255, 255, 190, 255], blend_mode: "Screen".to_string(), }, "BevelEmboss" => LayerStyle::BevelEmboss { enabled: new_enabled, depth: 1.0, size: 5.0, angle: 120.0, altitude: 30.0, highlight_opacity: 0.75, shadow_opacity: 0.75, direction: "Up".to_string(), style: "InnerBevel".to_string(), technique: "Smooth".to_string(), soften: 0.0, highlight_blend_mode: "Screen".to_string(), highlight_color: [255, 255, 255, 255], shadow_blend_mode: "Multiply".to_string(), shadow_color: [0, 0, 0, 255], }, "Satin" => LayerStyle::Satin { enabled: new_enabled, opacity: 0.5, angle: 120.0, distance: 11.0, size: 14.0, color: [0, 0, 0, 255], invert: true, }, "ColorOverlay" => LayerStyle::ColorOverlay { enabled: new_enabled, opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(), }, "GradientOverlay" => LayerStyle::GradientOverlay { enabled: new_enabled, opacity: 1.0, blend_mode: "Normal".to_string(), angle: 90.0, scale: 1.0, gradient_type: 0, }, "PatternOverlay" => LayerStyle::PatternOverlay { enabled: new_enabled, opacity: 1.0, blend_mode: "Normal".to_string(), scale: 1.0, pattern_name: "".to_string(), }, "Stroke" => LayerStyle::Stroke { enabled: new_enabled, size: 3.0, position: "Outside".to_string(), opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(), }, _ => unreachable!(), }; self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style); } } self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleSelect(index) => { self.selected_style = index; } Message::LayerStyleApply => { self.active_dialog = ActiveDialog::None; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleCancel => { self.active_dialog = ActiveDialog::None; } Message::LayerStyleAdd => { // Open layer style add dialog (placeholder) } Message::LayerStyleUpdateParam(index, param, value) => { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { matches!((disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) | ("InnerShadow", LayerStyle::InnerShadow { .. }) | ("OuterGlow", LayerStyle::OuterGlow { .. }) | ("InnerGlow", LayerStyle::InnerGlow { .. }) | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) | ("Satin", LayerStyle::Satin { .. }) | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) | ("Stroke", LayerStyle::Stroke { .. }) ) }); if let Some(style) = existing { let mut new_style = style.clone(); // Update the specific parameter match (&mut new_style, param.as_str()) { (LayerStyle::DropShadow { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::DropShadow { angle, .. }, "angle") => *angle = value, (LayerStyle::DropShadow { distance, .. }, "distance") => *distance = value, (LayerStyle::DropShadow { spread, .. }, "spread") => *spread = value, (LayerStyle::DropShadow { size, .. }, "size") => *size = value, (LayerStyle::InnerShadow { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::InnerShadow { angle, .. }, "angle") => *angle = value, (LayerStyle::InnerShadow { distance, .. }, "distance") => *distance = value, (LayerStyle::InnerShadow { spread, .. }, "spread") => *spread = value, (LayerStyle::InnerShadow { size, .. }, "size") => *size = value, (LayerStyle::OuterGlow { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::OuterGlow { spread, .. }, "spread") => *spread = value, (LayerStyle::OuterGlow { size, .. }, "size") => *size = value, (LayerStyle::InnerGlow { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::InnerGlow { spread, .. }, "spread") => *spread = value, (LayerStyle::InnerGlow { size, .. }, "size") => *size = value, (LayerStyle::BevelEmboss { depth, .. }, "depth") => *depth = value, (LayerStyle::BevelEmboss { size, .. }, "size") => *size = value, (LayerStyle::BevelEmboss { angle, .. }, "angle") => *angle = value, (LayerStyle::BevelEmboss { altitude, .. }, "altitude") => *altitude = value, (LayerStyle::BevelEmboss { soften, .. }, "soften") => *soften = value, (LayerStyle::Satin { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::Satin { angle, .. }, "angle") => *angle = value, (LayerStyle::Satin { distance, .. }, "distance") => *distance = value, (LayerStyle::Satin { size, .. }, "size") => *size = value, (LayerStyle::ColorOverlay { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::GradientOverlay { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::GradientOverlay { angle, .. }, "angle") => *angle = value, (LayerStyle::GradientOverlay { scale, .. }, "scale") => *scale = value, (LayerStyle::PatternOverlay { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::PatternOverlay { scale, .. }, "scale") => *scale = value, (LayerStyle::Stroke { size, .. }, "size") => *size = value, (LayerStyle::Stroke { opacity, .. }, "opacity") => *opacity = value, _ => {} } self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style); } } } self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateBlendMode(index, mode) => { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { matches!((disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) | ("InnerShadow", LayerStyle::InnerShadow { .. }) | ("OuterGlow", LayerStyle::OuterGlow { .. }) | ("InnerGlow", LayerStyle::InnerGlow { .. }) | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) | ("Satin", LayerStyle::Satin { .. }) | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) | ("Stroke", LayerStyle::Stroke { .. }) ) }); if let Some(style) = existing { let mut new_style = style.clone(); match &mut new_style { LayerStyle::DropShadow { blend_mode, .. } | LayerStyle::InnerShadow { blend_mode, .. } | LayerStyle::OuterGlow { blend_mode, .. } | LayerStyle::InnerGlow { blend_mode, .. } | LayerStyle::ColorOverlay { blend_mode, .. } | LayerStyle::GradientOverlay { blend_mode, .. } | LayerStyle::PatternOverlay { blend_mode, .. } | LayerStyle::Stroke { blend_mode, .. } => *blend_mode = mode, _ => {} } self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style); } } } self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateColor(index, new_color) => { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { matches!((disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) | ("InnerShadow", LayerStyle::InnerShadow { .. }) | ("OuterGlow", LayerStyle::OuterGlow { .. }) | ("InnerGlow", LayerStyle::InnerGlow { .. }) | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) | ("Satin", LayerStyle::Satin { .. }) | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) | ("Stroke", LayerStyle::Stroke { .. }) ) }); if let Some(style) = existing { let mut updated_style = style.clone(); match &mut updated_style { LayerStyle::DropShadow { color, .. } | LayerStyle::InnerShadow { color, .. } | LayerStyle::OuterGlow { color, .. } | LayerStyle::InnerGlow { color, .. } | LayerStyle::ColorOverlay { color, .. } | LayerStyle::Satin { color, .. } | LayerStyle::Stroke { color, .. } => *color = new_color, LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color = new_color, _ => {} } self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style); } } } // Update the HSL state for the picker let r = new_color[0] as f32 / 255.0; let g = new_color[1] as f32 / 255.0; let b = new_color[2] as f32 / 255.0; let max = r.max(g).max(b); let min = r.min(g).min(b); let l = (max + min) / 2.0; let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) }; let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 }; self.style_color_hsl = (h, s, l); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::ShowStyleColorPicker(idx) => { self.show_style_color_picker = true; self.style_color_picker_idx = idx; // Initialize HSL from current color let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 && idx < disc_names.len() { let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); let disc = disc_names[idx]; let color = styles.iter().find(|s| { matches!((disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) | ("InnerShadow", LayerStyle::InnerShadow { .. }) | ("OuterGlow", LayerStyle::OuterGlow { .. }) | ("InnerGlow", LayerStyle::InnerGlow { .. }) | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) | ("Satin", LayerStyle::Satin { .. }) | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) | ("Stroke", LayerStyle::Stroke { .. }) ) }).map(|s| match s { LayerStyle::DropShadow { color, .. } | LayerStyle::InnerShadow { color, .. } | LayerStyle::OuterGlow { color, .. } | LayerStyle::InnerGlow { color, .. } | LayerStyle::ColorOverlay { color, .. } | LayerStyle::Satin { color, .. } | LayerStyle::Stroke { color, .. } => *color, LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color, _ => [0, 0, 0, 255], }).unwrap_or([0, 0, 0, 255]); let r = color[0] as f32 / 255.0; let g = color[1] as f32 / 255.0; let b = color[2] as f32 / 255.0; let max = r.max(g).max(b); let min = r.min(g).min(b); let l = (max + min) / 2.0; let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) }; let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 }; self.style_color_hsl = (h, s, l); } } Message::HideStyleColorPicker => { self.show_style_color_picker = false; } Message::OpenLayerStyleDialog => { self.active_dialog = ActiveDialog::LayerStyleDialog; self.layer_style_offset = (0.0, 0.0); self.layer_style_dragging = false; self.layer_style_drag_start = None; } Message::LayerStyleDialogDragStart(_x, _y) => { // Drag started — begin tracking mouse movement. // The actual offset computation uses delta from the last position. self.layer_style_dragging = true; } Message::LayerStyleDialogDragMove(x, y) => { if self.layer_style_dragging { if let Some((lx, ly)) = self.layer_style_drag_start { // Compute delta from last position and add to offset let dx = x - lx; let dy = y - ly; self.layer_style_offset.0 += dx; self.layer_style_offset.1 += dy; } self.layer_style_drag_start = Some((x, y)); } } Message::LayerStyleDialogDragEnd => { self.layer_style_dragging = false; self.layer_style_drag_start = None; } // ── Brushes ─────────────────────────────────── Message::BrushStyleSelected(style) => { log::info!("Brush style selected: {:?}", style); self.tool_state.brush_style = style; } Message::BrushSizeChanged(size) => { self.tool_state.brush_size = size; } Message::BrushOpacityChanged(opacity) => { self.tool_state.brush_opacity = opacity; } Message::BrushHardnessChanged(hardness) => { self.tool_state.brush_hardness = hardness; } // ── Filters ─────────────────────────────────── Message::FilterSelect(filter) => { self.selected_filter = Some(filter); self.filter_preview_active = true; self.filter_params = serde_json::json!({}); self.documents[self.active_doc].engine.filter_preview_begin(); } Message::FilterParamChanged(key, value) => { // Update the specific parameter in filter_params if let serde_json::Value::Object(ref mut map) = self.filter_params { map.insert(key, value); } // Apply preview with updated params if let Some(filter) = self.selected_filter { let filter_id = filter_type_to_id(filter); self.documents[self.active_doc].engine.apply_filter_preview(filter_id, self.filter_params.clone()); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::FilterApply => { if let Some(filter) = self.selected_filter { let filter_id = filter_type_to_id(filter); self.documents[self.active_doc].engine.apply_filter(filter_id, self.filter_params.clone()); self.refresh_composite_if_needed(); self.filter_preview_active = false; self.selected_filter = None; self.filter_params = serde_json::json!({}); return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::FilterReset => { if self.filter_preview_active { self.documents[self.active_doc].engine.filter_preview_restore(); self.refresh_composite_if_needed(); self.filter_preview_active = false; } self.selected_filter = None; self.filter_params = serde_json::json!({}); } // ── Dialogs ─────────────────────────────────── Message::DialogOpen(dialog) => { self.active_dialog = dialog; } Message::DialogClose => { self.active_dialog = ActiveDialog::None; } Message::DialogCloseSave => { // TODO: save then close self.active_dialog = ActiveDialog::None; } Message::DialogCloseDiscard => { self.active_dialog = ActiveDialog::None; } // ── New Image Dialog ────────────────────────── Message::DialogNewImageName(name) => { self.dialog_new_name = name; } Message::DialogNewImageWidth(w) => { self.dialog_new_width = w; } Message::DialogNewImageHeight(h) => { self.dialog_new_height = h; } Message::DialogNewImageTransparent(t) => { self.dialog_new_transparent = t; } Message::DialogNewImagePreset(w, h) => { self.dialog_new_width = w; self.dialog_new_height = h; } Message::DialogNewImageCreate => { let w = self.dialog_new_width; let h = self.dialog_new_height; let name = self.dialog_new_name.clone(); let transparent = self.dialog_new_transparent; let mut engine = Engine::new_with_options(&name, w, h, transparent); engine.pre_tile_all_layers(); let composite_raw = engine.get_composite_pixels(); let cached_layers = engine.layer_infos(); let history_len = engine.history_len(); let cached_history: Vec<(usize, String)> = (0..history_len) .filter_map(|i| engine.history_description(i).map(|d| (i, d))) .collect(); let history_current = engine.history_current(); self.documents.push(IcedDocument { engine, composite_pixels: Arc::new(composite_raw.clone()), composite_raw, name, source_path: None, modified: false, zoom: 1.0, pan_offset: Vector::ZERO, cached_layers, cached_history, history_current, selection_rect: None, vector_draw: None, pane_size: (800.0, 600.0), render_generation: 0, dirty_region: std::cell::RefCell::new(None), full_upload: std::cell::RefCell::new(true), }); self.active_doc = self.documents.len() - 1; self.active_dialog = ActiveDialog::None; } // ── Adjustments Dialog ──────────────────────── Message::AdjBrightness(v) => { self.dialog_brightness = v; let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast}); self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjContrast(v) => { self.dialog_contrast = v; let params = serde_json::json!({"brightness": self.dialog_brightness, "contrast": v}); self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjHue(v) => { self.dialog_hue = v; let params = serde_json::json!({"hue": v, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness}); self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjSaturation(v) => { self.dialog_saturation = v; let params = serde_json::json!({"hue": self.dialog_hue, "saturation": v, "lightness": self.dialog_lightness}); self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjLightness(v) => { self.dialog_lightness = v; let params = serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": v}); self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjApply => { // Apply the adjustment permanently (not just preview) let params = match &self.active_dialog { ActiveDialog::BrightnessContrast => { serde_json::json!({"brightness": self.dialog_brightness, "contrast": self.dialog_contrast}) } ActiveDialog::HueSaturation => { serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness}) } _ => serde_json::json!({}), }; // First restore the preview, then apply permanently self.documents[self.active_doc].engine.filter_preview_restore(); let filter_id = match &self.active_dialog { ActiveDialog::BrightnessContrast => "brightness_contrast", ActiveDialog::HueSaturation => "hue_saturation", _ => "", }; if !filter_id.is_empty() { self.documents[self.active_doc].engine.apply_filter(filter_id, params); } self.refresh_composite_if_needed(); self.active_dialog = ActiveDialog::None; return Task::perform(async {}, |_| Message::CompositeRefresh); } // ── Selection Dialog ────────────────────────── Message::SelectionOpValue(v) => { self.dialog_selection_value = v; } Message::SelectionOpApply => { let v = self.dialog_selection_value as u32; match &self.active_dialog { ActiveDialog::SelectionOp(op) => { match *op { "Grow" => self.documents[self.active_doc].engine.selection_grow(v), "Shrink" => self.documents[self.active_doc].engine.selection_shrink(v), "Feather" => self.documents[self.active_doc].engine.selection_feather(v as f32), _ => {} } } _ => {} } self.refresh_composite_if_needed(); self.active_dialog = ActiveDialog::None; return Task::perform(async {}, |_| Message::CompositeRefresh); } // ── Selection tools ─────────────────────────── Message::SelectionDragStart(x, y) => { self.documents[self.active_doc].selection_rect = Some((x, y, x, y)); } Message::SelectionDragMove(x, y) => { if let Some((x0, y0, _, _)) = self.documents[self.active_doc].selection_rect { self.documents[self.active_doc].selection_rect = Some((x0, y0, x, y)); } } Message::SelectionDragEnd => { if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect.take() { let sx = x0.min(x1) as u32; let sy = y0.min(y1) as u32; let ex = x0.max(x1) as u32; let ey = y0.max(y1) as u32; if ex > sx && ey > sy { self.documents[self.active_doc].engine.create_selection_rect(sx, sy, ex, ey); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } } // ── Vector tools ────────────────────────────── Message::VectorDrawStart(x, y) => { self.documents[self.active_doc].vector_draw = Some(((x, y), (x, y))); } Message::VectorDrawMove(x, y) => { if let Some((start, _)) = self.documents[self.active_doc].vector_draw { self.documents[self.active_doc].vector_draw = Some((start, (x, y))); } } Message::VectorDrawEnd => { if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].vector_draw.take() { let engine = &mut self.documents[self.active_doc].engine; let color = self.fg_color; match self.tool_state.active_tool { Tool::VectorRect => { let shape = hcie_engine_api::VectorShape::Rect { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), fill: true, fill_color: color, stroke: 2.0, color, radius: 0.0, angle: 0.0, opacity: 1.0, hardness: 0.5, }; engine.add_vector_shape(shape); } Tool::VectorCircle => { let shape = hcie_engine_api::VectorShape::Circle { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), fill: true, fill_color: color, stroke: 2.0, color, angle: 0.0, opacity: 1.0, hardness: 0.5, }; engine.add_vector_shape(shape); } Tool::VectorLine => { let shape = hcie_engine_api::VectorShape::Line { x1: x0, y1: y0, x2: x1, y2: y1, stroke: 2.0, color, angle: 0.0, cap_start: hcie_engine_api::LineCap::Round, cap_end: hcie_engine_api::LineCap::Round, opacity: 1.0, hardness: 0.5, }; engine.add_vector_shape(shape); } _ => {} } self.refresh_composite_if_needed(); } } // ── AI Chat ─────────────────────────────────── Message::AiChatInput(_text) => { // Handled by the chat panel directly } Message::AiChatSend => { // Placeholder: would send to LLM log::info!("AI chat send not yet implemented"); } Message::AiChatClear => { // Handled by the chat panel directly } // ── Clipboard ───────────────────────────────── Message::CopyImage => { let doc = &self.documents[self.active_doc]; let w = doc.engine.canvas_width(); let h = doc.engine.canvas_height(); let pixels = doc.composite_raw.clone(); return Task::perform( async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) }, Message::ImageCopied, ); } Message::PasteImage => { return Task::perform( async { crate::io::clipboard::paste_image_from_clipboard() }, Message::ImagePasted, ); } Message::CopyText(text) => { let text = text.clone(); return Task::perform( async move { crate::io::clipboard::copy_text_to_clipboard(&text) }, |_| Message::NoOp, ); } Message::PasteText => { return Task::perform( async { crate::io::clipboard::paste_text_from_clipboard() }, Message::ClipboardResult, ); } Message::ClipboardResult(Ok(Some(text))) => { log::info!("Pasted text: {}", &text[..text.len().min(50)]); } Message::ClipboardResult(Ok(None)) => { log::info!("Clipboard is empty"); } Message::ClipboardResult(Err(e)) => { log::error!("Clipboard error: {}", e); } Message::ImageCopied(Ok(())) => { log::info!("Image copied to clipboard"); } Message::ImageCopied(Err(e)) => { log::error!("Failed to copy image: {}", e); } Message::ImagePasted(Ok(Some((pixels, w, h)))) => { log::info!("Pasted image: {}x{}", w, h); let engine = &mut self.documents[self.active_doc].engine; engine.create_layer_from_data("Pasted", pixels, w, h); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::ImagePasted(Ok(None)) => { log::info!("No image in clipboard"); } Message::ImagePasted(Err(e)) => { log::error!("Failed to paste image: {}", e); } // ── File I/O (with rfd) ─────────────────────── Message::OpenFileRfd => { return Task::perform( async { rfd::AsyncFileDialog::new() .set_title("Open Image") .add_filter("All Supported Types", &[ "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", "hcie", "psd", "kra", ]) .add_filter("Images", &[ "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", ]) .add_filter("HCIE Native", &["hcie"]) .add_filter("Photoshop", &["psd"]) .add_filter("Krita", &["kra"]) .pick_file() .await .map(|handle| handle.path().to_path_buf()) }, Message::FileDialogClosed, ); } Message::SaveFileAs => { return Task::perform( async { rfd::AsyncFileDialog::new() .set_title("Save As") .add_filter("HCIE Native", &["hcie"]) .add_filter("Photoshop", &["psd"]) .add_filter("Krita", &["kra"]) .add_filter("PNG", &["png"]) .add_filter("JPEG", &["jpg", "jpeg"]) .add_filter("WebP", &["webp"]) .add_filter("AVIF", &["avif"]) .add_filter("BMP", &["bmp"]) .add_filter("GIF", &["gif"]) .add_filter("TIFF", &["tiff", "tif"]) .add_filter("TGA", &["tga"]) .add_filter("OpenEXR", &["exr"]) .add_filter("All Supported Types", &[ "png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", "avif", "tga", "exr", "psd", "kra", "hcie", ]) .save_file() .await .map(|handle| handle.path().to_path_buf()) }, Message::FileDialogClosed, ); } Message::FileDialogClosed(Some(path)) => { let path_str = path.to_string_lossy().to_string(); let ext = path.extension() .and_then(|e| e.to_str()) .unwrap_or("png") .to_lowercase(); if self.active_dialog == ActiveDialog::None { // Open file — route multi-layer formats to dedicated importers let engine = &mut self.documents[self.active_doc].engine; let result = match ext.as_str() { "psd" => engine.import_psd(&path_str), "kra" => engine.import_kra(&path_str), "hcie" => engine.load_native(&path_str), _ => engine.open_image(&path_str), }; match result { Ok(()) => { self.documents[self.active_doc].engine.pre_tile_all_layers(); self.documents[self.active_doc].name = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled".to_string()); self.documents[self.active_doc].source_path = Some(path.clone()); self.add_recent_file(&path); self.documents[self.active_doc].full_upload.replace(true); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Err(e) => log::error!("Failed to open: {}", e), } } else { // Save file — route multi-layer formats to dedicated exporters let engine = &self.documents[self.active_doc].engine; let result = match ext.as_str() { "hcie" => engine.save_native(&path_str), "psd" => engine.export_psd(&path_str), "kra" => engine.export_kra(&path_str), _ => engine.save_as(&path_str, &ext), }; match result { Ok(()) => log::info!("Saved to {}", path_str), Err(e) => log::error!("Failed to save: {}", e), } self.active_dialog = ActiveDialog::None; } } Message::FileDialogClosed(None) => { // User cancelled } // ── Dock Layout ─────────────────────────────── Message::PaneClicked(pane) => { self.dock.focus(pane); } Message::PaneDragged(drag_event) => { if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = drag_event { if let iced::widget::pane_grid::Target::Pane(target_pane, _) = target { self.dock.pane_grid.swap(pane, target_pane); } } } Message::PaneResized(resize_event) => { self.dock.pane_grid.resize(resize_event.split, resize_event.ratio); } Message::PaneClose(pane) => { // Don't close canvas panes if let Some(PaneType::Canvas) = self.dock.pane_type(pane) { return Task::none(); } self.dock.close_pane(pane); } Message::PaneMaximize(pane) => { self.dock.toggle_maximize(pane); } // ── File I/O ───────────────────────────────── Message::NewDocument(w, h) => { let mut engine = Engine::new(w, h); engine.pre_tile_all_layers(); let composite_raw = engine.get_composite_pixels(); let cached_layers = engine.layer_infos(); let history_len = engine.history_len(); let cached_history: Vec<(usize, String)> = (0..history_len) .filter_map(|i| engine.history_description(i).map(|d| (i, d))) .collect(); let history_current = engine.history_current(); self.documents.push(IcedDocument { engine, composite_pixels: Arc::new(composite_raw.clone()), composite_raw, name: "Untitled".to_string(), source_path: None, modified: false, zoom: 1.0, pan_offset: Vector::ZERO, cached_layers, cached_history, history_current, selection_rect: None, vector_draw: None, pane_size: (800.0, 600.0), render_generation: 0, dirty_region: std::cell::RefCell::new(None), full_upload: std::cell::RefCell::new(true), }); } Message::DocSwitch(idx) => { if idx < self.documents.len() { self.active_doc = idx; } } Message::OpenFile => { return Task::perform( async { rfd::AsyncFileDialog::new() .set_title("Open Image") .add_filter("All Supported Types", &[ "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", "hcie", "psd", "kra", ]) .add_filter("Images", &[ "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", ]) .add_filter("HCIE Native", &["hcie"]) .add_filter("Photoshop", &["psd"]) .add_filter("Krita", &["kra"]) .pick_file() .await .map(|handle| handle.path().to_path_buf()) }, Message::FileDialogClosed, ); } Message::FileOpened(Ok(path)) => { log::info!("File opened: {:?}", path); } Message::FileOpened(Err(e)) => { log::error!("Failed to open file: {}", e); } Message::SaveFile => { return Task::perform( async { rfd::AsyncFileDialog::new() .set_title("Save As") .add_filter("HCIE Native", &["hcie"]) .add_filter("Photoshop", &["psd"]) .add_filter("Krita", &["kra"]) .add_filter("PNG", &["png"]) .add_filter("JPEG", &["jpg", "jpeg"]) .add_filter("WebP", &["webp"]) .add_filter("AVIF", &["avif"]) .add_filter("BMP", &["bmp"]) .add_filter("GIF", &["gif"]) .add_filter("TIFF", &["tiff", "tif"]) .add_filter("TGA", &["tga"]) .add_filter("OpenEXR", &["exr"]) .add_filter("All Supported Types", &[ "png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", "avif", "tga", "exr", "psd", "kra", "hcie", ]) .save_file() .await .map(|handle| handle.path().to_path_buf()) }, Message::FileDialogClosed, ); } // ── Menu ───────────────────────────────────── Message::MenuOpen(idx) => { if self.active_menu == Some(idx) { self.active_menu = None; } else { self.active_menu = Some(idx); } } Message::MenuClose => { if self.active_menu.is_some() { self.active_menu = None; } else if self.active_dialog != ActiveDialog::None { self.active_dialog = ActiveDialog::None; } } Message::OpenRecentFile(idx) => { self.active_menu = None; // Clone the path to avoid borrow conflict let path_str = self.recent_files.get(idx).map(|e| e.path.clone()); if let Some(path_str) = path_str { let path = std::path::PathBuf::from(&path_str); if path.exists() { let ext = path.extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let result = match ext.as_str() { "psd" => self.documents[self.active_doc].engine.import_psd(&path_str), "kra" => self.documents[self.active_doc].engine.import_kra(&path_str), "hcie" => self.documents[self.active_doc].engine.load_native(&path_str), _ => self.documents[self.active_doc].engine.open_image(&path_str), }; match result { Ok(()) => { self.documents[self.active_doc].engine.pre_tile_all_layers(); self.documents[self.active_doc].name = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled".to_string()); self.documents[self.active_doc].source_path = Some(path.clone()); self.add_recent_file(&path); let composite = self.documents[self.active_doc].engine.get_composite_pixels(); self.documents[self.active_doc].composite_raw = composite; self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(self.documents[self.active_doc].composite_raw.clone()); self.documents[self.active_doc].full_upload.replace(true); self.documents[self.active_doc].render_generation = self.documents[self.active_doc].render_generation.wrapping_add(1); self.documents[self.active_doc].cached_layers = self.documents[self.active_doc].engine.layer_infos(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Err(e) => log::error!("Failed to open recent file: {}", e), } } else { self.recent_files.retain(|e| e.path != path_str); } } } Message::ClearRecentFiles => { self.active_menu = None; self.recent_files.clear(); save_recent_files(&self.recent_files); } Message::MenuAction(menu_idx, item_idx) => { self.active_menu = None; match (menu_idx, item_idx) { (0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New (0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open (0, _) if item_idx >= 2 => { let recent_count = self.recent_files.len().min(10); let static_base = if recent_count > 0 { 4 + recent_count + 3 } else { 2 }; if item_idx >= static_base { match item_idx - static_base { 0 => return Task::perform(async {}, |_| Message::SaveFileAs), 1 => return Task::perform(async {}, |_| Message::SaveFileAs), 6 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG 7 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG 9 => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)), 10 => { if let Some(id) = self.window_id { return iced::window::close(id); } } _ => {} } } } // ── Edit menu (1) ── (1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo (1, 1) => return Task::perform(async {}, |_| Message::Redo), // Redo // (1, 2) = separator (1, 3) => return Task::perform(async {}, |_| Message::CopyImage), // Cut (copy for now) (1, 4) => return Task::perform(async {}, |_| Message::CopyImage), // Copy (1, 5) => return Task::perform(async {}, |_| Message::PasteImage), // Paste // (1, 6) = separator (1, 7) => { // Select All let w = self.documents[self.active_doc].engine.canvas_width() as f32; let h = self.documents[self.active_doc].engine.canvas_height() as f32; self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h)); } (1, 8) => { self.documents[self.active_doc].selection_rect = None; } // Deselect // (1, 9) = Invert Selection — TODO // (1, 10) = separator // (1, 11) = Fill — TODO // ── Tools menu (2) ── (2, 0) => self.tool_state.active_tool = Tool::Move, (2, 1) => self.tool_state.active_tool = Tool::VectorSelect, // (2, 2) = separator (2, 3) => self.tool_state.active_tool = Tool::Select, (2, 4) => self.tool_state.active_tool = Tool::Lasso, (2, 5) => self.tool_state.active_tool = Tool::PolygonSelect, (2, 6) => self.tool_state.active_tool = Tool::MagicWand, // (2, 7) = separator (2, 8) => self.tool_state.active_tool = Tool::Crop, (2, 9) => self.tool_state.active_tool = Tool::Eyedropper, // (2, 10) = separator (2, 11) => self.tool_state.active_tool = Tool::Brush, (2, 12) => self.tool_state.active_tool = Tool::Pen, (2, 13) => self.tool_state.active_tool = Tool::Spray, (2, 14) => self.tool_state.active_tool = Tool::Eraser, // (2, 15) = separator (2, 16) => self.tool_state.active_tool = Tool::FloodFill, (2, 17) => self.tool_state.active_tool = Tool::Gradient, // (2, 18) = separator (2, 19) => self.tool_state.active_tool = Tool::Text, // (2, 20) = separator (2, 21) => self.tool_state.active_tool = Tool::VectorLine, (2, 22) => self.tool_state.active_tool = Tool::VectorRect, (2, 23) => self.tool_state.active_tool = Tool::VectorCircle, // (2, 24) = separator // (2, 25) = Reset Tool — TODO // ── Image menu (3) ── // (3, 0) = Canvas Size — TODO // (3, 1) = Image Size — TODO // (3, 2) = separator // (3, 5) = Rotate 180 — TODO // (3, 6) = separator // (3, 9) = separator // (3, 10) = Crop to Selection — TODO // ── Layer menu (4) ── (4, 0) => return Task::perform(async {}, |_| Message::LayerAdd), // New Layer // (4, 1) = Duplicate Layer — TODO (4, 2) => { // Delete Layer let id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.delete_layer(id); } // (4, 3) = separator (4, 4) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down (4, 5) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image // (4, 6) = separator (4, 7) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Styles // (4, 10) = separator // (4, 11) = Align Left — TODO // (4, 12) = Align Center — TODO // (4, 13) = Align Right — TODO // ── Filter menu (5) ── // (5, 0) = ─ Blur (disabled) (5, 1) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::BoxBlur)), (5, 2) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::GaussianBlur)), (5, 3) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::MotionBlur)), // (5, 4) = ─ Sharpen (disabled) (5, 5) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::UnsharpMask)), // (5, 6) = ─ Pixelate (disabled) (5, 7) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Mosaic)), (5, 8) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Crystallize)), // (5, 9) = ─ Distort (disabled) (5, 10) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Pinch)), (5, 11) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Twirl)), // (5, 12) = ─ Stylize (disabled) (5, 13) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::OilPaint)), // (5, 14) = ─ Color & Light (disabled) (5, 15) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Levels)), (5, 16) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Vibrance)), (5, 17) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Exposure)), (5, 18) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Posterize)), (5, 19) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Threshold)), // (5, 20) = ─ Restore (disabled) (5, 21) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Dehaze)), // ── Select menu (6) ── (6, 0) => { // Select All let w = self.documents[self.active_doc].engine.canvas_width() as f32; let h = self.documents[self.active_doc].engine.canvas_height() as f32; self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h)); } (6, 1) => { self.documents[self.active_doc].selection_rect = None; } // Deselect // (6, 2) = Invert Selection — TODO // (6, 3) = separator (6, 4) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow (6, 5) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } // Shrink (6, 6) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } // Feather // ── View menu (7) ── (7, 0) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)), // Zoom In (7, 1) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)), // Zoom Out (7, 2) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // Zoom Reset // (7, 3) = separator (7, 4) => { // Fit to Window // Will be handled in view() with access to window size self.documents[self.active_doc].zoom = 1.0; } (7, 5) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // 100% (7, 6) => return Task::perform(async {}, |_| Message::CanvasZoomSet(2.0)), // 200% // (7, 7) = separator // (7, 8) = Debug Mode — TODO // ── Help menu (8) ── // (8, 0) = About — TODO // (8, 1) = Documentation — TODO // (8, 2) = License — TODO // Ignore separators and unimplemented items _ => {} } } // ── Window controls ────────────────────────── Message::WindowDrag => { if let Some(id) = self.window_id { return iced::window::drag(id); } } Message::WindowMinimize => { if let Some(id) = self.window_id { return iced::window::minimize(id, true); } } Message::WindowMaximize => { if let Some(id) = self.window_id { return iced::window::toggle_maximize(id); } } Message::WindowClose => { if let Some(id) = self.window_id { return iced::window::close(id); } } Message::WindowId(id) => { self.window_id = Some(id); } Message::NoOp => {} } Task::none() } // ── View ──────────────────────────────────────────── /// Build the UI element tree. /// /// Uses the dock system for the main layout. The title bar, menu bar, /// dock grid, and status bar surround the content. Dialogs overlay everything. pub fn view(&self) -> Element<'_, Message> { let view_start = std::time::Instant::now(); let doc = &self.documents[self.active_doc]; let colors = self.theme_state.colors(); // Title bar (now includes menu items and window controls) let title_bar = panels::title_bar::view( &doc.name, doc.zoom, doc.engine.canvas_width(), doc.engine.canvas_height(), colors, self.active_menu, ); // Toolbox — fixed 36px strip on the left edge (outside dock) let toolbox = crate::sidebar::view( &self.tool_state, &self.fg_color, &self.bg_color, colors, ); // Dock grid (without toolbox) let dock_content = crate::dock::view::dock_view(&self.dock, self); // Main area: toolbox + dock side by side with app background. // A small gap between the fixed toolbox and the dock makes the // workspace boundary visible (same dark bg_app color shows through). let main_area = iced::widget::row![toolbox, dock_content] .width(Length::Fill) .height(Length::Fill) .spacing(2); // Status bar let status_bar = panels::status_bar::view( &self.tool_state.active_tool, self.canvas_cursor_pos, doc.zoom, doc.engine.canvas_width(), doc.engine.canvas_height(), colors, ); let content = column![ title_bar, main_area, status_bar, ] .width(Length::Fill) .height(Length::Fill); let content = container(content) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_app)), ..Default::default() }); // Dialog overlay let dialog_overlay: Element<'_, Message> = match &self.active_dialog { ActiveDialog::None => text("").into(), ActiveDialog::NewImage => dialogs::new_image::view( &self.dialog_new_name, self.dialog_new_width, self.dialog_new_height, self.dialog_new_transparent, ), ActiveDialog::BrightnessContrast => dialogs::adjustments::brightness_contrast_view( self.dialog_brightness, self.dialog_contrast, ), ActiveDialog::HueSaturation => dialogs::adjustments::hsl_view( self.dialog_hue, self.dialog_saturation, self.dialog_lightness, ), ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name), ActiveDialog::SelectionOp(op) => dialogs::confirm::selection_op_view( op, self.dialog_selection_value, 1.0, 100.0, ), ActiveDialog::LayerStyleDialog => { let doc = &self.documents[self.active_doc]; let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id()); let styles: &'static [hcie_engine_api::LayerStyle] = Box::leak(styles.into_boxed_slice()); crate::panels::layer_styles::view( styles, self.selected_style, colors, self.show_style_color_picker, self.style_color_picker_idx, self.style_color_hsl, self.layer_style_offset, ) } }; // Stack main content with overlays (dialog + menu dropdown). // `content` is already wrapped in a styled container with the app // background, so no extra wrapper is needed. let has_dialog = self.active_dialog != ActiveDialog::None; let has_menu = self.active_menu.is_some(); if has_dialog || has_menu { let mut stack = iced::widget::Stack::new().push(content); if has_dialog { stack = stack.push(dialog_overlay); } if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files) { stack = stack.push(menu_overlay); } let _elapsed = view_start.elapsed(); // Performance log measuring Elm view reconstruction time. // useful to track widget layout allocation and view model reconstruction times. // log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0); stack.width(Length::Fill).height(Length::Fill).into() } else { let _elapsed = view_start.elapsed(); // Performance log measuring Elm view reconstruction time. // log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0); content.into() } } // ── Theme & Subscription ──────────────────────────── /// Return the application theme. pub fn theme(&self) -> Theme { Theme::Dark } /// Subscriptions — keyboard events for shortcuts. /// /// Handles common keyboard shortcuts: /// - Ctrl+Z: Undo /// - Ctrl+Y / Ctrl+Shift+Z: Redo /// - Ctrl+S: Save /// - Ctrl+N: New /// - Ctrl+O: Open /// - Ctrl+C: Copy /// - Ctrl+V: Paste /// - Escape: Close dialog pub fn subscription(&self) -> iced::Subscription { let keyboard = iced::keyboard::on_key_press(|key, modifiers| { let ctrl = modifiers.control() || modifiers.logo(); let shift = modifiers.shift(); match key { // Ctrl+Z = Undo iced::keyboard::Key::Character(ref c) if c.as_str() == "z" && ctrl && !shift => { Some(Message::Undo) } // Ctrl+Y or Ctrl+Shift+Z = Redo iced::keyboard::Key::Character(ref c) if c.as_str() == "y" && ctrl => { Some(Message::Redo) } iced::keyboard::Key::Character(ref c) if c.as_str() == "z" && ctrl && shift => { Some(Message::Redo) } // Ctrl+S = Save iced::keyboard::Key::Character(ref c) if c.as_str() == "s" && ctrl && !shift => { Some(Message::SaveFileAs) } // Ctrl+N = New iced::keyboard::Key::Character(ref c) if c.as_str() == "n" && ctrl => { Some(Message::DialogOpen(ActiveDialog::NewImage)) } // Ctrl+O = Open iced::keyboard::Key::Character(ref c) if c.as_str() == "o" && ctrl => { Some(Message::OpenFileRfd) } // Ctrl+C = Copy iced::keyboard::Key::Character(ref c) if c.as_str() == "c" && ctrl && !shift => { Some(Message::CopyImage) } // Ctrl+V = Paste iced::keyboard::Key::Character(ref c) if c.as_str() == "v" && ctrl && !shift => { Some(Message::PasteImage) } // Ctrl+X = Cut iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => { Some(Message::CopyImage) // TODO: cut } // Escape = close menu, cancel dialog, or deselect iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => { Some(Message::MenuClose) } _ => None, } }); // Mouse events for dialog dragging let mouse = iced::event::listen_with(|event, _status, _id| { if let iced::Event::Mouse(mouse_event) = event { match mouse_event { iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => { // Drag start is handled by mouse_area on_press None } iced::mouse::Event::CursorMoved { position } => { Some(Message::LayerStyleDialogDragMove(position.x, position.y)) } iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => { Some(Message::LayerStyleDialogDragEnd) } _ => None, } } else { None } }); iced::Subscription::batch(vec![keyboard, mouse]) } }