//! 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. mod transform_dirty; use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT}; use crate::dialogs; use crate::dock::manager::DockDragState; use crate::dock::state::{DockState, PaneType}; use crate::i18n; use crate::io::tablet::TabletState; use crate::panels; use crate::selection; use crate::selection::{CropState, SelectionTransform, TransformHandle}; use crate::theme::ThemeState; use self::transform_dirty::preview_bounds; use hcie_engine_api::{ BlendMode, BrushStyle, BrushTip, 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); } } /// Persisted color state (fg/bg/recent colors), mirroring egui's `hcie_colors`. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] struct PersistedColors { #[serde(default)] fg: Option<[u8; 4]>, #[serde(default)] bg: Option<[u8; 4]>, #[serde(default)] recent: Vec<[u8; 4]>, } /// Path to the persisted colors JSON (~/.config/hcie-iced/colors.json). fn colors_path() -> PathBuf { config_dir().join("colors.json") } /// Load persisted colors from disk (or defaults if missing). fn load_colors() -> PersistedColors { match std::fs::read_to_string(colors_path()) { Ok(json) => serde_json::from_str(&json).unwrap_or_default(), Err(_) => PersistedColors::default(), } } /// Save colors to disk. fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) { let data = PersistedColors { fg: Some(fg), bg: Some(bg), recent: recent.to_vec(), }; if let Ok(json) = serde_json::to_string_pretty(&data) { let _ = std::fs::write(colors_path(), json); } } /// Active dialog type. #[derive(Debug, Clone, PartialEq)] #[allow(dead_code)] pub enum ActiveDialog { None, NewImage, BrightnessContrast, HueSaturation, CloseConfirm, SelectionOp(&'static str), LayerStyleDialog, ImageSize, CanvasSize, About, } /// Top-level application state. pub struct HcieIcedApp { /// All open documents. pub documents: Vec, /// Whether the center document host shows the post-close welcome surface. pub show_welcome: bool, /// 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, /// Index of the active tool slot in the sidebar (for sub-tools). pub active_tool_slot: usize, /// Whether the sub-tools popup is open for the active slot. pub sub_tools_open: bool, /// Per-slot last-used sub-tool index (egui's `slot_last_used`). The sidebar /// shows the last-used tool's icon/label for each slot rather than always /// the primary tool. pub slot_last_used: Vec, /// Persistent settings (tool settings, panel layout, window). pub settings: crate::settings::AppSettings, /// Window ID for window control operations (drag, minimize, maximize, close). pub window_id: Option, /// Pending automated or keyboard-triggered full-viewport screenshot. pub screenshot_request: Option, /// New Image dialog state. pub dialog_input: String, pub dialog_input2: String, pub show_performance: bool, pub ui_scale: f32, pub modifiers: iced::keyboard::Modifiers, 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, /// Image Size dialog state. pub dialog_image_size_width: u32, pub dialog_image_size_height: u32, pub dialog_image_size_constrain: bool, pub dialog_image_size_original_ratio: f32, /// Canvas Size dialog state. pub dialog_canvas_size_width: u32, pub dialog_canvas_size_height: u32, pub dialog_canvas_size_anchor: (usize, usize), /// 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, /// Immutable active-layer pixels used by filter and adjustment previews. preview_baseline: Option, /// Original styles restored when the Layer Style dialog is cancelled. layer_style_baseline: Option, /// File operation associated with the currently open native file dialog. pending_file_operation: Option, /// Document tab awaiting Save/Discard confirmation before removal. pending_document_close: Option, /// 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]>, /// Target currently edited by the shared popup color selector. pub color_picker_target: Option, /// Live RGBA value displayed by the shared popup color selector. pub popup_color: [u8; 4], /// Editable hexadecimal value retained while the shared selector is open. pub popup_color_hex: String, /// Shared selector tab: RGB, wheel, or HSL. pub popup_color_tab: usize, /// 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), /// True when the color picker is editing the shadow color (BevelEmboss), /// false when editing the highlight color or the single-color style's color. pub style_color_is_shadow: bool, /// 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)>, /// Marching ants animation offset (cycles 0.0..1.0 for dashed line animation). pub marching_ants_offset: f32, /// AI chat state (messages, config, streaming). pub ai_chat_state: AiChatState, /// Abort handle for the currently active AI response stream. ai_chat_abort: Option, /// Script editor text content (raw string for error display/reference). pub script_text: String, /// Script editor content for the Iced text_editor widget. pub script_content: iced::widget::text_editor::Content, /// Last script parse/execution error message. pub script_error: Option, /// Line number (1-indexed) of the last error, for highlighting. pub script_error_line: Option, /// Whether a script is currently executing. pub script_is_running: bool, /// AI script generator — natural language prompt input. pub ai_script_prompt: String, /// AI script generator — generated DSL script output. pub ai_script_output: String, /// AI script generator — last error message (if any). pub ai_script_error: Option, /// AI script generator — last successful generation/copy/execution notice. pub ai_script_notice: Option, /// AI script generator — whether a generation request is in progress. pub ai_script_is_running: bool, /// AI script generator — selected provider type ("ollama", "claude", "openai"). pub ai_script_provider: String, /// AI script generator — model name for the selected provider. pub ai_script_model: String, /// AI script generator — base URL for the selected provider. pub ai_script_url: String, /// Whether the FastStone-like image viewer is active. pub viewer_active: bool, /// Viewer state for directory browsing and thumbnail grid. pub viewer_state: crate::viewer::ViewerState, /// Name of the currently active dock profile (if any). pub active_dock_profile: Option, /// Text input for new dock profile name. pub dock_profile_new_name: String, /// Index of the dock profile being renamed (if any). pub dock_profile_rename_idx: Option, /// Text input for renaming a dock profile. pub dock_profile_rename_buf: String, /// Whether the dock profile dialog is visible. pub show_dock_profile_dialog: bool, /// Current UI language for internationalization. pub language: i18n::Language, /// Context menu screen position (x, y). None means closed. pub canvas_context_menu: Option<(f32, f32)>, /// Dirty flag set whenever fg/bg/recent colors change, so colors can be /// persisted to disk (egui persists these under the `hcie_colors` key). pub colors_dirty: bool, /// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration). pub vector_shapes_snapshot: Option, /// Last drag position during vector shape editing (canvas coordinates). pub vector_drag_last: Option<(f32, f32)>, /// Timestamp of the last expensive vector rerasterization during a drag. pub vector_drag_last_render: Option, /// Immutable basis for the active vector drag. pub vector_drag_session: Option, /// Boolean operation shape A selector index (geometry panel). pub bool_shape_a: Option, /// Boolean operation shape B selector index (geometry panel). pub bool_shape_b: Option, /// Whether the toolbox is expanded to 2 columns (false = single column 36px, true = 2 columns 72px). pub sidebar_expanded: bool, } /// A recently opened file entry. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RecentFileEntry { pub path: String, pub name: String, } /// Immutable pixels captured before a non-destructive GUI preview begins. #[derive(Debug, Clone)] struct PreviewBaseline { document_index: usize, layer_id: u64, pixels: Vec, } /// Layer-style state captured when the modal editor opens. #[derive(Debug, Clone)] struct LayerStyleBaseline { document_index: usize, layer_id: u64, styles: Vec, was_modified: bool, changed: bool, } /// Distinguishes open and save callbacks and carries close-after-save continuation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PendingFileOperation { Open, SaveAs { close_after: bool }, } /// 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, /// Active selection transform (floating pixels after cut/copy-paste) pub selection_transform: Option, /// Transform handle being dragged pub transform_drag_handle: TransformHandle, /// Transform drag start position in canvas coordinates pub transform_drag_start: Option<(f32, f32)>, /// Original transform state before drag (for undo) pub transform_original: Option, /// Source pixels and placement removed when a move transform began, for exact Cancel restore. pub transform_source: Option, /// Original active-layer pixels for the transform's single history transaction. pub transform_layer_baseline: Option>, /// Original engine selection mask restored when a transform is cancelled. pub transform_selection_baseline: Option>, /// Composite snapshot without the floating transform, used as the base for each preview frame. pub transform_preview_base: Option>, /// Internal clipboard for copy/paste operations pub internal_clipboard: Option, /// Selection mask (alpha channel) for the current selection pub selection_mask: Option>, /// Whether the selection mask has changed since last GPU upload pub selection_mask_dirty: std::cell::Cell, /// Selection bounds (x, y, width, height) pub selection_bounds: Option<(u32, u32, u32, u32)>, /// History of selection masks for undo support. pub selection_history: Vec>>, /// Engine history index when the selection was last modified. pub selection_history_marker: i32, /// Crop tool state pub crop_state: CropState, /// Accumulated lasso points (freehand selection) in canvas coordinates. pub lasso_points: Vec<(u32, u32)>, /// Accumulated polygon vertices (polygonal lasso) in canvas coordinates. pub polygon_points: Vec<(u32, u32)>, /// Gradient drag start/current endpoints in canvas coordinates. pub gradient_drag: Option<((f32, f32), (f32, f32))>, /// VisionSelect bounding-box drag (start, current) in canvas coordinates. pub vision_rect: Option<(f32, f32, f32, f32)>, /// Draft text state for the on-canvas text tool. pub text_draft: Option, /// Cache for extracted selection edges (marching ants). pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache, /// Extracted selection edges for overlay rendering. pub marching_ants_edges: Option>>, /// Horizontal runs of selected pixels used to fill irregular masks exactly. pub selection_fill_spans: Option>>, /// How newly generated selection masks modify the existing mask. pub selection_mode: selection::SelectionMode, /// In-memory saved selection for the document's Load/Save Selection commands. pub saved_selection_mask: Option>, /// Whether quick-mask visualization is enabled. pub quick_mask: bool, /// Index of the currently selected vector shape (None = no selection). pub selected_vector_shape: Option, /// Index into the list of shapes at the current click position for cycling /// through overlapping vector shapes. Resets to 0 when clicking a new position. pub vector_cycle_index: usize, /// Which vector edit handle is active (hovered or being dragged). pub vector_edit_handle: hcie_engine_api::VectorEditHandle, } /// In-progress text layer being authored with the Text tool. /// /// Holds the editable content plus font/size/color/alignment/orientation so the /// properties panel and canvas overlay can render a live preview before the /// text is committed to the engine as a Text layer. #[derive(Debug)] pub struct TextDraft { pub content: String, pub font: String, pub size: f32, pub color: [u8; 4], pub x: f32, pub y: f32, pub angle: f32, pub alignment: hcie_engine_api::TextAlignment, pub orientation: hcie_engine_api::TextOrientation, /// Stateful multiline editor content kept in sync with `content`. pub editor: iced::widget::text_editor::Content, } impl Default for TextDraft { fn default() -> Self { Self { content: String::new(), font: "Arial".to_string(), size: 24.0, color: [0, 0, 0, 255], x: 100.0, y: 100.0, angle: 0.0, alignment: hcie_engine_api::TextAlignment::Left, orientation: hcie_engine_api::TextOrientation::Horizontal, editor: iced::widget::text_editor::Content::new(), } } } /// 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, /// Imported brush presets from ABR/KPP files. pub imported_brushes: Vec, /// True if currently holding shift to resize brush pub is_resizing_brush: bool, /// Starting brush size before resize drag pub brush_resize_start_size: f32, /// Starting pointer position for brush resize drag pub brush_resize_start_pos: Option<(f32, f32)>, /// Last click time for double-click detection pub last_click_time: std::time::Instant, /// Last click pos for double-click detection pub last_click_pos: Option<(f32, f32)>, /// Whether Space is held, temporarily routing left drag to canvas panning. pub space_pan: bool, } 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(), imported_brushes: Vec::new(), is_resizing_brush: false, brush_resize_start_size: 20.0, brush_resize_start_pos: None, last_click_time: std::time::Instant::now(), last_click_pos: None, space_pan: false, } } } /// Messages the application handles. #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Message { // ── Tool selection ────────────────────────────────── ToolSelected(Tool), ToolSlotClicked(usize, Tool), /// Opens or closes a slot's subtool chooser without changing the active tool. SubtoolsToggle(usize), /// Dismisses a transient toolbox or menu popup after an uncaptured pointer press. OverlayOutsideClick, // ── Color ─────────────────────────────────────────── FgColorChanged([u8; 4]), BgColorChanged([u8; 4]), SwapColors, ResetColors, ColorTabChanged(usize), // ── Canvas interaction ────────────────────────────── CanvasPointerPressed { x: f32, y: f32, }, CanvasPointerMoved { x: f32, y: f32, }, CanvasPointerReleased, CanvasPointerRightClicked { x: f32, y: f32, screen_x: f32, screen_y: f32, }, CanvasContextMenuClose, CanvasPanZoom { zoom: f32, pan_offset: Vector, }, CanvasZoomSet(f32), CanvasZoomRelative(f32), FitToArea, CanvasSize((f32, f32)), CanvasCursorPos { x: u32, y: u32, }, ModifiersChanged(iced::keyboard::Modifiers), SpacePanChanged(bool), // ── 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]), LayerStyleUpdateString(usize, String, String), LayerStyleUpdateShadowColor(usize, [u8; 4]), LayerStyleUpdateShadowBlend(usize, String), LayerStyleUpdateShadowOpacity(usize, f32), ShowStyleColorPicker(usize), ShowStyleShadowColorPicker(usize), HideStyleColorPicker, OpenColorPicker(crate::color_picker::ColorPickerTarget, [u8; 4]), CloseColorPicker, ColorPickerTabChanged(usize), ColorPickerHexChanged(String), PopupColorChanged([u8; 4]), OpenLayerStyleDialog, LayerStyleDialogDragStart(f32, f32), LayerStyleDialogDragMove(f32, f32), LayerStyleDialogDragEnd, LayerStyleAdd, // ── Brushes ───────────────────────────────────────── BrushStyleSelected(BrushStyle), BrushSizeChanged(f32), BrushSizeRelative(f32), BrushOpacityChanged(f32), BrushHardnessChanged(f32), BrushFlowChanged(f32), BrushSpacingChanged(f32), BrushImportAbr, BrushImportAbrFile(Result, String>), ResetToolDefaults, // ── Per-tool options (selection / spray / vector / text / gradient) ─ SelectionToleranceChanged(u8), SelectionContiguousToggled(bool), SelectionAntiAliasToggled(bool), SelectionFeatherChanged(f32), SprayParticleSizeChanged(f32), SprayDensityChanged(u32), GradientTypeChanged(u32), VectorStrokeChanged(f32), VectorOpacityChanged(f32), VectorFillToggled(bool), VectorRadiusChanged(f32), VectorPointsChanged(u32), VectorSidesChanged(u32), TextFontChanged(String), TextAngleChanged(f32), TextOrientationChanged(String), TextContentChanged(String), TextEditorAction(iced::widget::text_editor::Action), TextCommit, ToolApplyToNewLayerToggled(bool), // ── Filters ───────────────────────────────────────── FilterSelect(FilterType), FilterApply, FilterReset, FilterParamChanged(String, serde_json::Value), FilterPreviewToggle(bool), FilterPreviewApply, FilterPreviewRestore, // ── 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, // ── Image Size Dialog ────────────────────────────── DialogImageSizeWidth(u32), DialogImageSizeHeight(u32), DialogImageSizeConstrain(bool), DialogImageSizeApply, // ── Canvas Size Dialog ───────────────────────────── DialogCanvasSizeWidth(u32), DialogCanvasSizeHeight(u32), DialogCanvasSizeAnchor(usize, usize), DialogCanvasSizeApply, // ── Selection tools ───────────────────────────────── SelectionDragStart(f32, f32), SelectionDragMove(f32, f32), SelectionDragEnd, SelectAll, Deselect, SelectInverse, SelectionModeChanged(selection::SelectionMode), SelectionSave, SelectionLoad, QuickMaskToggle, ClearPixels, // ── Selection transform ───────────────────────────── TransformDragStart(f32, f32), TransformDragMove(f32, f32), TransformDragEnd, TransformApply, TransformCancel, // ── Vector tools ──────────────────────────────────── VectorDrawStart(f32, f32), VectorDrawMove(f32, f32), VectorDrawEnd, // ── Vector select / edit ──────────────────────────── VectorSelectClick { x: f32, y: f32, }, VectorSelectDragStart { x: f32, y: f32, }, VectorSelectDragMove { x: f32, y: f32, }, VectorSelectDragEnd, VectorShapeDelete, VectorShapeSelect(Option), VectorShapeApply, VectorShapeCancel, VectorShapeFlipH, VectorShapeFlipV, // ── Geometry panel controls ────────────────────────── GeometryBoundsX1(f32), GeometryBoundsY1(f32), GeometryBoundsX2(f32), GeometryBoundsY2(f32), GeometryFillToggled(bool), GeometryFillColorChanged([u8; 4]), GeometryStrokeColorChanged([u8; 4]), GeometryOpacityChanged(f32), GeometryHardnessChanged(f32), GeometryBoolShapeA(Option), GeometryBoolShapeB(Option), GeometryBooleanOp(hcie_engine_api::VectorBooleanOp), GeometryLineCapStart(hcie_engine_api::LineCap), GeometryLineCapEnd(hcie_engine_api::LineCap), // ── Lasso / polygon selection ─────────────────────── LassoDragMove(f32, f32), LassoDragEnd, PolygonAddPoint(f32, f32), PolygonClose, // ── Gradient tool drag ────────────────────────────── GradientDragStart(f32, f32), GradientDragMove(f32, f32), GradientDragEnd, // ── Crop tool ────────────────────────────────────── CropDragStart(f32, f32), CropDragMove(f32, f32), CropDragEnd, CropConfirm, CropCancel, // ── Script Editor ─────────────────────────────────── ScriptEditorAction(iced::widget::text_editor::Action), ScriptTextChanged(String), ScriptRun, ScriptClear, // ── AI Chat ───────────────────────────────────────── AiChatInput(String), AiChatSend, AiChatClear, AiChatProviderChanged(String), AiChatUrlChanged(String), AiChatModelChanged(String), AiChatReasoningToggled(bool), AiChatCanvasContextToggled(bool), AiChatStreamChunk(String), AiChatReasoningChunk(String), AiChatStreamFinished(Result), AiChatCancel, AiChatCopyTranscript, // ── AI Script Generator ───────────────────────────── AiScriptPromptChanged(String), AiScriptProviderChanged(String), AiScriptModelChanged(String), AiScriptUrlChanged(String), AiScriptGenerate, AiScriptRun, AiScriptCopy, AiScriptCopyOutput, AiScriptClear, AiScriptResult(Result), // ── Clipboard ─────────────────────────────────────── CopyImage, CutImage, 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), /// Begins a reliable custom drag from the visible dock title content. DockHeaderDragStart(iced::widget::pane_grid::Pane), PaneResized(iced::widget::pane_grid::ResizeEvent), PaneClose(iced::widget::pane_grid::Pane), PaneMaximize(iced::widget::pane_grid::Pane), /// Moves a tool pane to the fixed right auto-hide rail. PaneAutoHide(iced::widget::pane_grid::Pane), /// Moves a docked tool into an in-viewport floating card. PaneFloat(iced::widget::pane_grid::Pane), /// Begins dragging a floating card header. FloatingDragStart(PaneType), /// Raises a clicked floating card without beginning a drag. FloatingFocus(PaneType), /// Redocks a floating tool beside Canvas. FloatingRedock(PaneType), /// Minimizes a floating tool to the fixed right auto-hide rail. FloatingAutoHide(PaneType), /// Closes a floating tool placement. FloatingClose(PaneType), /// Shared full-window logical cursor feed for dock and dialog drags. DockPointerMoved(f32, f32), /// Ends active full-window floating and dialog drags. DockPointerReleased, /// Clears click-only or outside-release dock state after PaneGrid handles the release event. DockReleaseCleanup, /// Tracks the current logical viewport for geometry and clamping. DockViewportChanged(iced::Size), /// Opens one temporary auto-hide overlay. AutoHideActivate(PaneType), /// Closes the temporary overlay without changing placement. AutoHideDismiss, /// Pins an auto-hidden panel back into PaneGrid. AutoHideRestore(PaneType), // ── File I/O ──────────────────────────────────────── NewDocument(u32, u32), DocSwitch(usize), /// Requests closure of one document tab without closing the application window. DocClose(usize), OpenFile, FileOpened(Result), SaveFile, // ── Menu ───────────────────────────────────────────── MenuOpen(usize), MenuClose, /// Executes a semantic command supplied by an enabled menu leaf. MenuCommand(crate::panels::menus::MenuCommand), // ── Window ─────────────────────────────────────────── WindowDrag, WindowMinimize, WindowMaximize, WindowClose, WindowId(iced::window::Id), ScreenshotCapture, ScreenshotReady(iced::window::Screenshot), ThemeChanged(crate::theme::ThemePreset), // ── Dock Profiles ─────────────────────────────────── DockProfileSave(String), DockProfileLoad(String), DockProfileRename(String, String), DockProfileDelete(String), DockProfileRenameStart(String), DockProfileRenameConfirm, DockProfileRenameCancel, DockProfileNewNameChanged(String), DockProfileRenameChanged(String), DockProfileDialogShow, DockProfileDialogHide, // ── Misc ──────────────────────────────────────────── NoOp, // ── Tablet / touch feed ───────────────────────────── TabletTouch { x: f32, y: f32, pressed: bool, }, // ── Contextual key actions (resolved in update with self) ── EscapePressed, EnterPressed, // ── Image Viewer ──────────────────────────────────── ViewerToggle, ViewerSetMode(crate::viewer::ViewerMode), ViewerNavigate(std::path::PathBuf), ViewerSelectFile(std::path::PathBuf), ViewerOpenFile(std::path::PathBuf), ViewerRefresh, ViewerFullscreen(bool), ViewerPrev, ViewerNext, ViewerEnter, // ── Toolbox expand/collapse ───────────────────────── SidebarToggleExpanded, } /// 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() } } /// Convert a display label back to a PaneType for dock profile loading. fn pane_type_from_label(label: &str) -> Option { use crate::dock::state::PaneType; match label { "Canvas" => Some(PaneType::Canvas), "Layers" => Some(PaneType::Layers), "History" => Some(PaneType::History), "Brushes & Tips" => Some(PaneType::Brushes), "Filters" => Some(PaneType::Filters), "Color Palette" => Some(PaneType::ColorPicker), "Properties" => Some(PaneType::Properties), "Script" => Some(PaneType::Script), "AI Assistant" => Some(PaneType::AiChat), "Layer Details" => Some(PaneType::LayerDetails), "Geometry" => Some(PaneType::Geometry), "Tool Settings" => Some(PaneType::ToolSettings), "AI Script Generator" => Some(PaneType::AiScript), _ => None, } } /// 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, } } /// Whether a tool is a vector-shape tool that uses the shared drag flow. fn is_vector_shape_tool(tool: Tool) -> bool { matches!( tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine | Tool::VectorArrow | Tool::VectorStar | Tool::VectorPolygon | Tool::VectorRhombus | Tool::VectorCylinder | Tool::VectorHeart | Tool::VectorBubble | Tool::VectorGear | Tool::VectorCross | Tool::VectorCrescent | Tool::VectorBolt | Tool::VectorArrow4 ) } /// Clears mask-covered alpha values without creating an engine history entry. /// /// **Purpose:** Starts a move transaction while deferring history creation until Apply, avoiding /// the engine's standalone `Clear Selection` snapshot used by the Delete command. /// /// **Logic & Workflow:** Visits one mask byte per canvas pixel and clears only the corresponding /// layer alpha byte when coverage is non-zero, matching `Engine::clear_selection_pixels`. /// /// **Arguments:** `pixels` is active-layer RGBA data and `mask` is one-byte selection coverage. /// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates `pixels`; performs no allocation, /// engine call, or history operation. fn clear_masked_pixels_without_history(pixels: &mut [u8], mask: &[u8]) { for (index, coverage) in mask.iter().copied().enumerate() { let alpha_index = index * 4 + 3; if coverage > 0 && alpha_index < pixels.len() { pixels[alpha_index] = 0; } } } /// Restores a rectangular region from a stable transform preview baseline. /// /// **Purpose:** Removes the prior floating preview without restoring the full canvas per pointer /// event. **Logic & Workflow:** Copies each row in the clipped exclusive rectangle from `source` /// to `destination`. **Arguments:** Buffers are full-canvas RGBA data, `canvas_width` is their row /// stride, and `bounds` is `[x0, y0, x1, y1]`. **Returns:** Nothing. /// **Side Effects / Dependencies:** Mutates only `destination` rows inside `bounds`; no allocation. fn restore_preview_region( destination: &mut [u8], source: &[u8], canvas_width: u32, bounds: [u32; 4], ) { let row_bytes = (bounds[2] - bounds[0]) as usize * 4; for y in bounds[1]..bounds[3] { let start = (y as usize * canvas_width as usize + bounds[0] as usize) * 4; let end = start + row_bytes; if end <= destination.len() && end <= source.len() { destination[start..end].copy_from_slice(&source[start..end]); } } } /// Applies a floating transform and records exactly one caller-labelled layer snapshot. /// /// **Purpose:** Commits move and paste transforms atomically using the same inverse-affine sampler /// as preview. **Logic & Workflow:** Reads the current non-preview layer, composites the transform, /// installs final pixels, and pushes the supplied original baseline only when pixels changed. /// /// **Arguments:** `engine` is the API boundary, `tr` is floating geometry, canvas dimensions define /// raster clipping, `before_pixels` is the transaction baseline, and `history_name` labels undo. /// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates the active layer and may append one /// engine history snapshot; emits DEBUG timing and transaction-boundary logs. fn apply_transform_to_layer( engine: &mut hcie_engine_api::Engine, tr: &selection::SelectionTransform, canvas_w: u32, canvas_h: u32, before_pixels: Vec, history_name: &str, ) { let started = std::time::Instant::now(); if tr.is_empty() || canvas_w == 0 || canvas_h == 0 { return; } let Some(mut dest) = engine.get_active_layer_pixels() else { return; }; let active_idx = engine.active_layer_index(); let bounds = selection::transform::composite_transform(&mut dest, canvas_w, canvas_h, tr); log::debug!( "[TransformApply] begin history_name={history_name:?}, active_layer={active_idx}, bounds={bounds:?}" ); if before_pixels != dest { engine.set_active_layer_pixels(dest); engine.push_active_layer_snapshot(before_pixels, active_idx, history_name); } log::debug!( "[TransformApply] end history_name={history_name:?}, elapsed_us={}", started.elapsed().as_micros() ); } #[cfg(test)] mod transform_transaction_tests { use super::apply_transform_to_layer; use crate::selection::SelectionTransform; /// Verifies that move commit creates one correctly named history boundary. /// /// **Purpose:** Prevents reintroduction of a preliminary `Clear Selection` snapshot before the /// final move. **Logic & Workflow:** Starts from a transparent layer, applies one floating pixel /// with an explicit original baseline, and checks history growth and description. /// **Arguments & Returns:** No arguments or return value; assertions report transaction errors. /// **Side Effects / Dependencies:** Uses an in-memory engine only and performs no I/O. #[test] fn move_commit_pushes_one_named_snapshot() { let mut engine = hcie_engine_api::Engine::new(4, 4); let baseline = engine.get_active_layer_pixels().unwrap(); let history_before = engine.history_len(); let transform = SelectionTransform { pixels: vec![255, 0, 0, 255], width: 1, height: 1, pos: iced::Vector::new(2.0, 1.0), size: iced::Vector::new(1.0, 1.0), rotation: 0.0, }; apply_transform_to_layer(&mut engine, &transform, 4, 4, baseline, "Move Selection"); assert_eq!(engine.history_len(), history_before + 1); assert_eq!( engine.history_description(history_before).as_deref(), Some("Move Selection") ); } } /// Renders the current floating selection and publishes only its old/new union as dirty. /// /// **Purpose:** Keeps interactive transforms responsive while retaining the protected RefCell /// dirty-region accumulator and shader partial-upload path. /// /// **Logic & Workflow:** Restores only the previous transformed AABB from the stable base, invokes /// the shared inverse-affine compositor for the current AABB, unions both with any pending dirty /// region, and updates the shader Arc in place when uniquely owned. A full-buffer Arc clone is used /// only when Iced still owns the prior frame's immutable Arc. /// /// **Arguments:** `doc` contains transform/composite state and `previous_bounds` identifies stale /// preview pixels. **Returns:** Nothing. **Side Effects / Dependencies:** Mutates preview buffers, /// the dirty-region RefCell, and render generation; logs bounds and elapsed time at DEBUG level. fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32; 4]>) { let started = std::time::Instant::now(); let canvas_width = doc.engine.canvas_width(); let canvas_height = doc.engine.canvas_height(); if let (Some(base), Some(bounds)) = (doc.transform_preview_base.as_ref(), previous_bounds) { restore_preview_region(&mut doc.composite_raw, base, canvas_width, bounds); } let current_bounds = doc.selection_transform.as_ref().and_then(|transform| { selection::transform::composite_transform( &mut doc.composite_raw, canvas_width, canvas_height, transform, ) }); let changed_bounds = match (previous_bounds, current_bounds) { (Some(previous), Some(current)) => Some(union_regions(Some(previous), current)), (Some(bounds), None) | (None, Some(bounds)) => Some(bounds), (None, None) => None, }; if let Some(bounds) = changed_bounds { let pending = doc.dirty_region.replace(None); let upload_bounds = union_regions(pending, bounds); doc.dirty_region.replace(Some(upload_bounds)); if let Some(shader_pixels) = Arc::get_mut(&mut doc.composite_pixels) { restore_preview_region(shader_pixels, &doc.composite_raw, canvas_width, upload_bounds); } else { doc.composite_pixels = Arc::new(doc.composite_raw.clone()); } doc.render_generation = doc.render_generation.wrapping_add(1); log::debug!( "[TransformPreview] previous={previous_bounds:?}, current={current_bounds:?}, upload={upload_bounds:?}, elapsed_us={}", started.elapsed().as_micros() ); } } impl HcieIcedApp { /// Combines the engine's newly generated selection with the previous mask. /// /// **Arguments:** `previous` is the selection mask captured before an engine selection call. /// **Returns:** Nothing. **Side Effects / Dependencies:** Replaces the engine selection mask /// through `hcie-engine-api` when add/subtract/intersect mode is active. fn combine_current_selection(&mut self, previous: Option>) { let doc = &mut self.documents[self.active_doc]; let current_marker = doc.engine.history_current(); if current_marker != doc.selection_history_marker { doc.selection_history.clear(); doc.selection_history_marker = current_marker; } doc.selection_history.push(previous.clone()); let Some(new_mask) = doc.engine.get_selection_mask().map(ToOwned::to_owned) else { return; }; let combined = selection::state::combine_masks(previous.as_deref(), &new_mask, doc.selection_mode); doc.engine.set_selection_mask(combined); } /// 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, screenshot_request: 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), selection_transform: None, transform_drag_handle: TransformHandle::None, transform_drag_start: None, transform_original: None, transform_source: None, transform_layer_baseline: None, transform_selection_baseline: None, transform_preview_base: None, internal_clipboard: None, selection_mask: None, selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, selection_history: Vec::new(), selection_history_marker: 0, crop_state: CropState::default(), lasso_points: Vec::new(), polygon_points: Vec::new(), gradient_drag: None, vision_rect: None, text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, selection_fill_spans: None, selection_mode: selection::SelectionMode::Replace, saved_selection_mask: None, quick_mask: false, selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, }; // Load saved settings let settings = crate::settings::AppSettings::load(); let mut app = Self { documents: vec![doc], show_welcome: false, 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, active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush).unwrap_or(0), sub_tools_open: false, sidebar_expanded: settings.panel_layout.sidebar_expanded, slot_last_used: vec![0; 20], dialog_input: String::new(), dialog_input2: String::new(), show_performance: false, ui_scale: 1.0, modifiers: iced::keyboard::Modifiers::default(), canvas_context_menu: None, settings: settings.clone(), window_id: None, screenshot_request, 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, dialog_image_size_width: 800, dialog_image_size_height: 600, dialog_image_size_constrain: true, dialog_image_size_original_ratio: 800.0 / 600.0, dialog_canvas_size_width: 800, dialog_canvas_size_height: 600, dialog_canvas_size_anchor: (1, 1), selected_filter: None, filter_params: serde_json::json!({}), filter_preview_active: false, preview_baseline: None, layer_style_baseline: None, pending_file_operation: None, pending_document_close: None, dock: settings.panel_layout.restore_dock(), tablet_state: Arc::new(Mutex::new(TabletState::new())), initialized: false, theme_state: ThemeState::new(settings.theme_preset), active_menu: None, recent_files: load_recent_files(), recent_colors: Vec::new(), color_picker_target: None, popup_color: [0, 0, 0, 255], popup_color_hex: "000000FF".to_string(), popup_color_tab: 1, selected_style: 0, show_style_color_picker: false, style_color_picker_idx: 0, style_color_hsl: (0.0, 0.0, 0.0), style_color_is_shadow: false, color_tab: 0, layer_style_offset: (0.0, 0.0), layer_style_dragging: false, layer_style_drag_start: None, marching_ants_offset: 0.0, ai_chat_state: AiChatState::default(), ai_chat_abort: None, script_text: crate::script::parser::DEFAULT_SCRIPT.to_string(), script_content: iced::widget::text_editor::Content::with_text( crate::script::parser::DEFAULT_SCRIPT, ), script_error: None, script_error_line: None, script_is_running: false, ai_script_prompt: String::new(), ai_script_output: String::new(), ai_script_error: None, ai_script_notice: None, ai_script_is_running: false, ai_script_provider: "ollama".to_string(), ai_script_model: "qwen2.5:7b".to_string(), ai_script_url: "http://localhost:11434".to_string(), viewer_active: false, viewer_state: crate::viewer::ViewerState::default(), active_dock_profile: None, dock_profile_new_name: String::new(), dock_profile_rename_idx: None, dock_profile_rename_buf: String::new(), show_dock_profile_dialog: false, language: i18n::Language::default(), colors_dirty: false, vector_shapes_snapshot: None, vector_drag_last: None, vector_drag_last_render: None, vector_drag_session: None, bool_shape_a: None, bool_shape_b: None, }; if let Some(panel) = app .screenshot_request .as_ref() .and_then(|request| request.panel.as_deref()) .and_then(crate::dock::state::PaneType::from_label) { app.dock.reopen_pane(panel); } if let Some(panel) = app .screenshot_request .as_ref() .and_then(|request| request.floating_panel.as_deref()) .and_then(crate::dock::state::PaneType::from_label) { app.dock.reopen_pane(panel); if let Some(pane) = app.dock.pane_for_type(panel) { app.dock.float_pane(pane, (settings.window.width, settings.window.height)); } } if let Some(panel) = app .screenshot_request .as_ref() .and_then(|request| request.auto_hide_panel.as_deref()) .and_then(crate::dock::state::PaneType::from_label) { app.dock.reopen_pane(panel); if let Some(pane) = app.dock.pane_for_type(panel) { app.dock.auto_hide_pane( pane, Some(crate::dock::manager::DockEdge::Right), ); } } if app .screenshot_request .as_ref() .is_some_and(|request| request.welcome) { app.show_welcome = true; } // Apply saved settings to tool state settings.apply_to_tool_state(&mut app.tool_state); // Restore persisted colors (fg/bg/recent) from the colors file, mirroring // egui's `hcie_colors` JSON persistence in app/mod.rs:380-386. let colors = load_colors(); if colors.fg.is_some() { app.fg_color = colors.fg.unwrap(); } if colors.bg.is_some() { app.bg_color = colors.bg.unwrap(); } if !colors.recent.is_empty() { app.recent_colors = colors.recent; } let fg = app.fg_color; app.active_document_mut().engine.set_color(fg); app.settings = settings; // 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); 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); 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 } } /// Hit-test which vector edit handle is at the given canvas-space position. /// /// Checks rotation handle first (circle area), then resize handles (square /// hit-box), then the move handle (inside bounding box). Returns the /// matching `VectorEditHandle` variant. Coordinates are in canvas-space. fn vector_hit_test_handle(&self, cx: f32, cy: f32) -> hcie_engine_api::VectorEditHandle { use hcie_engine_api::VectorEditHandle as VEH; let doc = &self.documents[self.active_doc]; let Some(idx) = doc.selected_vector_shape else { return VEH::None; }; let layer_id = doc.engine.active_layer_id(); let shapes = match doc.engine.active_vector_shapes() { Some(s) => s, None => return VEH::None, }; let shape = match shapes.get(idx) { Some(s) => s, None => return VEH::None, }; let bounds = shape.normalized_bounds(); let angle = doc.engine.vector_shape_angle(layer_id, idx); crate::vector_edit::hit_test_handle(bounds, angle, (cx, cy), doc.zoom) } /// 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); } /// Captures the active layer once so repeated previews always start from identical pixels. fn begin_preview(&mut self) -> bool { self.restore_preview(); let document_index = self.active_doc; let layer_id = self.documents[document_index].engine.active_layer_id(); let Some(pixels) = self.documents[document_index] .engine .get_layer_pixels(layer_id) else { return false; }; self.preview_baseline = Some(PreviewBaseline { document_index, layer_id, pixels, }); true } /// Restores preview pixels without consuming the immutable baseline. fn reset_preview_to_baseline(&mut self) -> bool { let Some(baseline) = self.preview_baseline.as_ref() else { return false; }; if baseline.document_index >= self.documents.len() { return false; } self.documents[baseline.document_index] .engine .set_layer_pixels(baseline.layer_id, baseline.pixels.clone()); true } /// Restores preview pixels and ends the current preview session. fn restore_preview(&mut self) -> bool { let restored = self.reset_preview_to_baseline(); self.preview_baseline = None; restored } /// Saves a document using extension-based engine API routing. fn save_document_to( &self, document_index: usize, path: &std::path::Path, ) -> Result<(), String> { let path_str = path.to_string_lossy(); let extension = path .extension() .and_then(|value| value.to_str()) .unwrap_or("") .to_ascii_lowercase(); if extension.is_empty() { return Err("Save As requires a supported file extension".to_string()); } let engine = &self.documents[document_index].engine; match extension.as_str() { "hcie" => engine.save_native(&path_str), "psd" => engine.export_psd(&path_str), "kra" => engine.export_kra(&path_str), "png" | "jpg" | "jpeg" | "webp" | "avif" | "bmp" | "gif" | "tiff" | "tif" | "tga" | "exr" => engine.save_as(&path_str, &extension), _ => Err(format!("Unsupported save extension: {extension}")), } } /// Applies successful-save identity updates and records the path as recent. fn finish_successful_save(&mut self, document_index: usize, path: std::path::PathBuf) { let name = path .file_name() .map(|value| value.to_string_lossy().into_owned()) .unwrap_or_else(|| "Untitled".to_string()); let doc = &mut self.documents[document_index]; doc.source_path = Some(path.clone()); doc.name = name; doc.modified = false; self.add_recent_file(&path); } /// Continues window-close processing after one document has saved successfully. fn continue_close_after_save(&mut self) -> Task { if let Some(document_index) = self.pending_document_close.take() { self.active_dialog = ActiveDialog::None; self.close_document_tab(document_index); return Task::none(); } if let Some(next) = self.documents.iter().position(|document| document.modified) { self.active_doc = next; self.active_dialog = ActiveDialog::CloseConfirm; Task::none() } else { self.active_dialog = ActiveDialog::None; self.window_id.map_or_else(Task::none, iced::window::close) } } /// Removes one document tab and keeps the editor shell alive for the final tab. /// /// Arguments: `document_index` is the tab to remove. Returns: Nothing. Logic & Workflow: /// indices before the active tab shift it left; closing the active tab selects the tab now at /// that position or the preceding final tab. Side Effects: Drops the document and edit state. fn close_document_tab(&mut self, document_index: usize) { if document_index >= self.documents.len() { return; } if self.documents.len() == 1 { let _ = self.update(Message::NewDocument(800, 600)); self.documents.remove(document_index); self.active_doc = 0; self.active_dialog = ActiveDialog::None; self.show_welcome = true; self.pending_document_close = None; self.vector_shapes_snapshot = None; self.vector_drag_session = None; self.vector_drag_last = None; self.vector_drag_last_render = None; return; } self.documents.remove(document_index); self.active_doc = active_index_after_document_close( self.active_doc, document_index, self.documents.len(), ); self.vector_shapes_snapshot = None; self.vector_drag_session = None; self.vector_drag_last = None; } /// 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() { let (dirty_rect, ptr, len) = doc.engine.render_composite_region(); if len > 0 && !ptr.is_null() { let mut is_full_copy = false; // Resize composite_raw if canvas dimensions changed (e.g. after PSD import) if doc.composite_raw.len() != len { doc.composite_raw.resize(len, 0); doc.full_upload.replace(true); is_full_copy = true; } // If the canvas changed size, we MUST force a full upload because the old memory // structure is no longer valid, and a partial copy would leave zeros in the new buffer. if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) { let w = doc.engine.canvas_width() as usize; let h = doc.engine.canvas_height() as usize; let [x0, y0, x1, y1] = rect; let x0 = x0 as usize; let y0 = y0 as usize; let x1 = x1 as usize; let y1 = y1 as usize; let row_bytes = (x1 - x0) * 4; if row_bytes > 0 { unsafe { let src_ptr = ptr; let dst_raw_ptr = doc.composite_raw.as_mut_ptr(); let mut pixels_ptr = std::ptr::null_mut(); let mut need_new_arc = false; if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) { // Important: resize arc if needed before taking mutable pointer if pixels.len() != len { pixels.resize(len, 0); } pixels_ptr = pixels.as_mut_ptr(); } else { need_new_arc = true; } for y in y0..y1 { let offset = (y * w + x0) * 4; std::ptr::copy_nonoverlapping( src_ptr.add(offset), dst_raw_ptr.add(offset), row_bytes ); if !pixels_ptr.is_null() { std::ptr::copy_nonoverlapping( src_ptr.add(offset), pixels_ptr.add(offset), row_bytes ); } } if need_new_arc { doc.composite_pixels = Arc::new(doc.composite_raw.clone()); } } } let current = doc.dirty_region.replace(None); doc.dirty_region.replace(Some(union_regions(current, rect))); } else { // Full update unsafe { std::ptr::copy_nonoverlapping(ptr, doc.composite_raw.as_mut_ptr(), len); } if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) { if pixels.len() != len { pixels.resize(len, 0); } unsafe { std::ptr::copy_nonoverlapping(ptr, pixels.as_mut_ptr(), len); } } 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(); } // Update selection edge cache if mask changed if let Some(mask) = doc.engine.get_selection_mask() { doc.selection_mask = Some(mask.to_vec()); doc.selection_mask_dirty.set(true); doc.selection_bounds = selection::state::mask_bounds( mask, doc.engine.canvas_width(), doc.engine.canvas_height(), ); doc.selection_fill_spans = Some(Arc::new(selection::state::selected_spans( mask, doc.engine.canvas_width(), doc.engine.canvas_height(), ))); let edges = doc.selection_edge_cache.get(mask).unwrap_or_else(|| { doc.selection_edge_cache.rebuild( mask, doc.engine.canvas_width(), doc.engine.canvas_height(), ) }); doc.marching_ants_edges = Some(edges); } else { doc.selection_mask = None; doc.selection_mask_dirty.set(true); doc.selection_bounds = None; doc.selection_fill_spans = None; doc.marching_ants_edges = None; // Clear cache if selection is dropped so next time it won't mistakenly hit doc.selection_edge_cache = Default::default(); } // 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 mut tip = build_brush_tip(&self.tool_state); match self.tool_state.active_tool { Tool::Pen => { tip.style = BrushStyle::Round; tip.size = self.settings.tool_settings.pen_size; tip.spacing = 0.05; } Tool::Spray => { tip.style = BrushStyle::Spray; tip.size = self.settings.tool_settings.spray_radius; tip.spray_particle_size = self.settings.tool_settings.spray_particle_size; tip.spray_density = self.settings.tool_settings.spray_density; tip.spacing = 0.5; } _ => {} } 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; // Update marching ants animation offset (cycles at ~60fps for smooth animation) // The offset cycles from 0.0 to 1.0, completing one cycle every ~1 second if self .documents .iter() .any(|doc| doc.selection_bounds.is_some()) { self.marching_ants_offset = (self.marching_ants_offset + since_last.as_secs_f32() * 0.5) % 1.0; } match message { Message::ToolSelected(tool) => { log::info!("Tool selected: {:?}", tool); self.tool_state.active_tool = tool; self.sub_tools_open = false; // Record this tool as the last-used one for its slot, so the // sidebar shows the last-used tool's icon next time (egui's // slot_last_used behavior at tool_state.rs:278-286). if let Some(slot_idx) = crate::sidebar::slot_for_tool(tool) { self.active_tool_slot = slot_idx; if let Some(slot) = crate::sidebar::tool_slots().get(slot_idx) { if let Some(tidx) = slot.tools.iter().position(|&t| t == tool) { if slot_idx < self.slot_last_used.len() { self.slot_last_used[slot_idx] = tidx; } } } } self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } Message::ToolSlotClicked(slot_idx, primary_tool) => { self.active_tool_slot = slot_idx; let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used); self.tool_state.active_tool = if crate::sidebar::tool_slots().get(slot_idx).is_some() { tool } else { primary_tool }; self.sub_tools_open = false; } Message::SubtoolsToggle(slot_idx) => { let supports_popup = crate::sidebar::tool_slots() .get(slot_idx) .is_some_and(|slot| slot.tools.len() > 1); if supports_popup { let opening = !(self.sub_tools_open && self.active_tool_slot == slot_idx); self.sub_tools_open = opening; self.active_tool_slot = slot_idx; if opening && !self.sidebar_expanded { self.sidebar_expanded = true; self.settings.panel_layout.sidebar_expanded = true; self.settings.panel_layout.sidebar_width = 72.0; let _ = self.settings.save(); } } } Message::OverlayOutsideClick => { if self.dock.active_auto_hide.take().is_some() { return Task::none(); } if self.sub_tools_open { self.sub_tools_open = false; } else { self.active_menu = None; } } Message::FgColorChanged(color) => { self.fg_color = color; self.active_document_mut().engine.set_color(color); self.add_recent_color(color); self.colors_dirty = true; crate::shape_sync::sync_shape_from_tool(self); self.refresh_composite_if_needed(); } Message::BgColorChanged(color) => { self.bg_color = color; crate::shape_sync::sync_shape_from_tool(self); self.refresh_composite_if_needed(); } 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); self.add_recent_color(color); self.colors_dirty = true; crate::shape_sync::sync_shape_from_tool(self); self.refresh_composite_if_needed(); } Message::ResetColors => { // Reset fg=black, bg=white (egui: toolbox.rs:351-354). self.fg_color = [0, 0, 0, 255]; self.bg_color = [255, 255, 255, 255]; let fg = self.fg_color; self.active_document_mut().engine.set_color(fg); self.colors_dirty = true; crate::shape_sync::sync_shape_from_tool(self); self.refresh_composite_if_needed(); } Message::ColorTabChanged(tab) => { self.color_tab = tab; } Message::OpenColorPicker(target, color) => { self.color_picker_target = Some(target); self.popup_color = color; self.popup_color_hex = format!( "{:02X}{:02X}{:02X}{:02X}", color[0], color[1], color[2], color[3] ); } Message::CloseColorPicker => { self.color_picker_target = None; } Message::ColorPickerTabChanged(tab) => { self.popup_color_tab = tab.min(2); } Message::ColorPickerHexChanged(value) => { self.popup_color_hex = value.clone(); if let Some(color) = crate::color_picker::parse_hex_color(&value, self.popup_color[3]) { return self.update(Message::PopupColorChanged(color)); } } Message::PopupColorChanged(color) => { self.popup_color = color; self.popup_color_hex = format!( "{:02X}{:02X}{:02X}{:02X}", color[0], color[1], color[2], color[3] ); let routed = match self.color_picker_target { Some(crate::color_picker::ColorPickerTarget::Foreground) => { Some(Message::FgColorChanged(color)) } Some(crate::color_picker::ColorPickerTarget::Background) => { Some(Message::BgColorChanged(color)) } Some(crate::color_picker::ColorPickerTarget::GeometryFill) => { Some(Message::GeometryFillColorChanged(color)) } Some(crate::color_picker::ColorPickerTarget::GeometryStroke) => { Some(Message::GeometryStrokeColorChanged(color)) } Some(crate::color_picker::ColorPickerTarget::StyleMain(index)) | Some(crate::color_picker::ColorPickerTarget::StyleHighlight(index)) => { Some(Message::LayerStyleUpdateColor(index, color)) } Some(crate::color_picker::ColorPickerTarget::StyleShadow(index)) => { Some(Message::LayerStyleUpdateShadowColor(index, color)) } None => None, }; if let Some(message) = routed { return self.update(message); } } 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; if let Some(transform) = self.documents[self.active_doc].selection_transform.as_ref() { let handle = transform.hit_test(x, y, self.documents[self.active_doc].zoom); if handle != TransformHandle::None { self.documents[self.active_doc].transform_drag_handle = handle; return self.update(Message::TransformDragStart(x, y)); } } let now = std::time::Instant::now(); let _is_double_click = if let Some((lx, ly)) = self.tool_state.last_click_pos { let dx = canvas_x - lx; let dy = canvas_y - ly; let dist_sq = dx * dx + dy * dy; now.duration_since(self.tool_state.last_click_time) .as_millis() < 300 && dist_sq < 25.0 } else { false }; self.tool_state.last_click_time = now; self.tool_state.last_click_pos = Some((canvas_x, canvas_y)); let paint_tool = matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray); if paint_tool && (self.modifiers.control() || self.modifiers.logo()) { let cx = canvas_x as u32; let cy = canvas_y as u32; let width = self.documents[self.active_doc].engine.canvas_width(); if cx < width && cy < self.documents[self.active_doc].engine.canvas_height() { let index = ((cy * width + cx) * 4) as usize; if index + 3 < self.documents[self.active_doc].composite_raw.len() { let pixels = &self.documents[self.active_doc].composite_raw; let color = [ pixels[index], pixels[index + 1], pixels[index + 2], pixels[index + 3], ]; self.fg_color = color; self.documents[self.active_doc].engine.set_color(color); } } return Task::none(); } if paint_tool && self.modifiers.shift() { self.tool_state.is_resizing_brush = true; self.tool_state.is_drawing = true; self.tool_state.brush_resize_start_size = self.tool_state.brush_size; self.tool_state.brush_resize_start_pos = Some((canvas_x, canvas_y)); return Task::none(); } if let Some(req_type) = tool.allowed_layer_type() { // Reject raster edits on non-editable layers if req_type == hcie_engine_api::LayerType::Raster && !self.documents[self.active_doc] .engine .active_layer_is_editable() { // In the future: emit a status message here return Task::none(); } // Auto-create layer if current tool requires a different layer type // NOTE: Vector layer creation is handled by add_vector_shape() in VectorDrawEnd // to avoid creating duplicate layers. Only create Raster layers here. if req_type != hcie_engine_api::LayerType::Text && req_type != hcie_engine_api::LayerType::Vector { if let Some(active) = self.documents[self.active_doc].engine.active_layer() { if active.layer_type != req_type { let id = self.documents[self.active_doc].engine.add_layer("Layer"); self.documents[self.active_doc].engine.set_active_layer(id); self.refresh_composite_if_needed(); } } } } 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); if tool != Tool::Pen { 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; let tol = self.settings.tool_settings.flood_fill_tolerance as u8; self.documents[self.active_doc] .engine .flood_fill(cx, cy, color, tol); 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::MagicWand => { let cx = canvas_x as u32; let cy = canvas_y as u32; let tol = self.settings.tool_settings.magic_wand_tolerance as u8; let previous = self.documents[self.active_doc] .engine .get_selection_mask() .map(ToOwned::to_owned); self.documents[self.active_doc] .engine .create_selection_magic_wand(cx, cy, tol); self.combine_current_selection(previous); self.refresh_composite_if_needed(); } Tool::SmartSelect => { // No dedicated AI selection API exposed by the engine; // fall back to a magic-wand pick at the click point so the // tool remains usable without a configured SAM backend. let cx = canvas_x as u32; let cy = canvas_y as u32; let tol = self.settings.tool_settings.magic_wand_tolerance as u8; self.documents[self.active_doc] .engine .create_selection_magic_wand(cx, cy, tol); self.documents[self.active_doc].selection_bounds = { let w = self.documents[self.active_doc].engine.canvas_width(); let h = self.documents[self.active_doc].engine.canvas_height(); Some((0, 0, w, h)) }; self.refresh_composite_if_needed(); } Tool::Lasso => { // Start a freehand lasso path. self.documents[self.active_doc].lasso_points.clear(); let cx = canvas_x as u32; let cy = canvas_y as u32; self.documents[self.active_doc].lasso_points.push((cx, cy)); self.tool_state.is_drawing = true; } Tool::PolygonSelect => { // Polygon lasso: accumulate vertices on each click. Close when // clicking near the first vertex (≥3 points) or double-clicking. let cx = canvas_x as u32; let cy = canvas_y as u32; let pts = &self.documents[self.active_doc].polygon_points; let close_dist: i32 = 10; let near_start = pts.len() >= 3 && (pts[0].0 as i32 - cx as i32).abs() <= close_dist && (pts[0].1 as i32 - cy as i32).abs() <= close_dist; let near_last = pts .last() .map(|l| { (l.0 as i32 - cx as i32).abs() <= 4 && (l.1 as i32 - cy as i32).abs() <= 4 }) .unwrap_or(false); if near_start || near_last { // Finalize polygon selection. let pts = std::mem::take(&mut self.documents[self.active_doc].polygon_points); if pts.len() >= 3 { let pts_ref: Vec<(u32, u32)> = pts; self.documents[self.active_doc] .engine .create_selection_polygon(&pts_ref); self.refresh_composite_if_needed(); } } else { self.documents[self.active_doc] .polygon_points .push((cx, cy)); } } Tool::VisionSelect => { // Start a bounding-box drag for vision segmentation. self.tool_state.is_drawing = true; self.documents[self.active_doc].vision_rect = Some((canvas_x, canvas_y, canvas_x, canvas_y)); } Tool::Move => { // Begin a floating-pixel transform from the active // selection mask, clearing the source area first. let doc = &mut self.documents[self.active_doc]; let mask = doc.engine.get_selection_mask().map(|m| m.to_vec()); let w = doc.engine.canvas_width(); let h = doc.engine.canvas_height(); if let Some(mask_data) = mask { let cx = canvas_x as u32; let cy = canvas_y as u32; let in_selection = (cx < w && cy < h) && mask_data[(cy * w + cx) as usize] > 0; if in_selection { let tr = selection::SelectionTransform::from_engine( &mut doc.engine, &mask_data, w, h, ); if !tr.is_empty() { let Some(original_layer) = doc.engine.get_active_layer_pixels() else { return Task::none(); }; let mut cut_layer = original_layer.clone(); clear_masked_pixels_without_history(&mut cut_layer, &mask_data); log::debug!( "[TransformMove] begin atomic transaction bounds={:?}, selected_pixels={}", preview_bounds(&tr, w, h), mask_data.iter().filter(|coverage| **coverage > 0).count() ); doc.engine.set_active_layer_pixels(cut_layer); doc.engine.selection_clear(); doc.selection_mask = Some(mask_data.clone()); doc.selection_mask_dirty.set(true); doc.transform_source = Some(tr.clone()); doc.transform_layer_baseline = Some(original_layer); doc.transform_selection_baseline = Some(mask_data); doc.selection_transform = Some(tr); doc.transform_drag_handle = TransformHandle::Move; doc.transform_drag_start = Some((canvas_x, canvas_y)); // Force a composite refresh so the cleared source // area is visible immediately. doc.engine.mark_composite_dirty(); self.refresh_composite_if_needed(); let doc = &mut self.documents[self.active_doc]; doc.transform_preview_base = Some(doc.composite_raw.clone()); render_transform_preview(doc, None); } } } } Tool::Gradient => { // Start a gradient endpoint drag. let sx = canvas_x; let sy = canvas_y; return Task::perform(async move {}, move |_| { Message::GradientDragStart(sx, sy) }); } Tool::Text => { // Begin (or continue) an on-canvas text draft at the // click point. A second click elsewhere moves the draft. let cx = canvas_x; let cy = canvas_y; let doc = &mut self.documents[self.active_doc]; if doc.text_draft.is_none() { let mut draft = TextDraft::default(); draft.color = self.fg_color; draft.x = cx; draft.y = cy; doc.text_draft = Some(draft); } else { doc.text_draft.as_mut().unwrap().x = cx; doc.text_draft.as_mut().unwrap().y = cy; } } Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine | Tool::VectorArrow | Tool::VectorStar | Tool::VectorPolygon | Tool::VectorRhombus | Tool::VectorCylinder | Tool::VectorHeart | Tool::VectorBubble | Tool::VectorGear | Tool::VectorCross | Tool::VectorCrescent | Tool::VectorBolt | Tool::VectorArrow4 => { // Start vector shape drawing (all shape tools share one drag). let sx = canvas_x; let sy = canvas_y; return Task::perform(async move {}, move |_| { Message::VectorDrawStart(sx, sy) }); } Tool::Crop => { // Start crop drag let sx = canvas_x; let sy = canvas_y; return Task::perform(async move {}, move |_| { Message::CropDragStart(sx, sy) }); } Tool::VectorSelect => { // Click to select vector shapes under cursor. let cx = canvas_x; let cy = canvas_y; return Task::perform(async move {}, move |_| Message::VectorSelectClick { x: cx, y: cy, }); } _ => {} } } 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.documents[self.active_doc] .transform_drag_start .is_some() { return self.update(Message::TransformDragMove(x, y)); } if self.tool_state.is_drawing { // Selection tools (Lasso, Polygon, Vision, Rect) use is_drawing // to track drag state but must not enter the brush code path. let is_selection_tool = matches!( self.tool_state.active_tool, Tool::Lasso | Tool::PolygonSelect | Tool::Select | Tool::VisionSelect | Tool::MagicWand | Tool::SmartSelect ); if is_selection_tool { // Fall through to the dedicated selection drag handlers below. } else { let is_ctrl = self.modifiers.control() || self.modifiers.logo(); if is_ctrl { // Pick color let cx = x as u32; let cy = 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); } } } else if self.tool_state.is_resizing_brush { if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos { let dx = x - start_x; let new_size = (self.tool_state.brush_resize_start_size + dx).clamp(1.0, 500.0); self.tool_state.brush_size = new_size; match self.tool_state.active_tool { Tool::Pen => self.settings.tool_settings.pen_size = new_size, Tool::Brush => self.settings.tool_settings.brush_size = new_size, Tool::Eraser => self.settings.tool_settings.eraser_size = new_size, Tool::Spray => self.settings.tool_settings.spray_radius = new_size, _ => {} } } } else { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if self.tool_state.active_tool == Tool::Pen { if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { if !self.documents[self.active_doc].engine.is_stroke_active() { self.documents[self.active_doc] .engine .begin_stroke(layer_id, last_x, last_y); } let pressure = self .tablet_state .lock() .map(|state| state.current_pressure()) .unwrap_or(1.0); self.documents[self.active_doc] .engine .draw_pen_segment(layer_id, last_x, last_y, x, y, pressure); self.tool_state.last_stroke_pos = Some((x, y)); self.refresh_composite_if_needed(); } return Task::none(); } 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; 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)); } } } // end else (non-selection tool) } // 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 lasso freehand drag (accumulate points). if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { let mx = x; let my = y; return Task::perform(async move {}, move |_| Message::LassoDragMove(mx, my)); } // Handle vision-select bounding-box drag. if self.tool_state.active_tool == Tool::VisionSelect && self.tool_state.is_drawing { let mx = x; let my = y; return Task::perform(async move {}, move |_| { Message::GradientDragMove(mx, my) }); } // Handle gradient endpoint drag. if self.tool_state.active_tool == Tool::Gradient && self.tool_state.is_drawing { let mx = x; let my = y; return Task::perform(async move {}, move |_| { Message::GradientDragMove(mx, my) }); } // Handle vector draw drag (all shape tools share one drag). if is_vector_shape_tool(self.tool_state.active_tool) { let mx = x; let my = y; return Task::perform(async move {}, move |_| Message::VectorDrawMove(mx, my)); } // Handle crop drag if self.tool_state.active_tool == Tool::Crop { let mx = x; let my = y; return Task::perform(async move {}, move |_| Message::CropDragMove(mx, my)); } // Handle vector select drag if self.tool_state.active_tool == Tool::VectorSelect && self.vector_drag_last.is_some() { let mx = x; let my = y; return Task::perform(async move {}, move |_| Message::VectorSelectDragMove { x: mx, y: my, }); } } Message::CanvasPointerReleased => { if self.tool_state.is_resizing_brush { self.tool_state.is_resizing_brush = false; self.tool_state.is_drawing = false; self.tool_state.brush_resize_start_pos = None; self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); return Task::none(); } if self.documents[self.active_doc] .transform_drag_start .is_some() { return self.update(Message::TransformDragEnd); } // Lasso release finalizes a freehand selection path. if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { self.tool_state.is_drawing = false; return Task::perform(async {}, |_| Message::LassoDragEnd); } // VisionSelect release finalizes the bbox as a rectangular selection. if self.tool_state.active_tool == Tool::VisionSelect && self.tool_state.is_drawing { self.tool_state.is_drawing = false; let rect = self.documents[self.active_doc].vision_rect; if let Some((x0, y0, x1, y1)) = rect { let sx = x0.min(x1) as u32; let sy = y0.min(y1) as u32; let ex = x0.max(x1) as u32; let ey = y0.max(y1) as u32; if ex > sx && ey > sy { self.documents[self.active_doc] .engine .create_selection_rect(sx, sy, ex, ey); self.combine_current_selection(None); self.documents[self.active_doc].selection_bounds = Some((sx, sy, ex - sx, ey - sy)); self.refresh_composite_if_needed(); } } self.documents[self.active_doc].vision_rect = None; } // Gradient release applies a linear/radial gradient fill. if self.tool_state.active_tool == Tool::Gradient && self.tool_state.is_drawing { self.tool_state.is_drawing = false; return Task::perform(async {}, |_| Message::GradientDragEnd); } if self.tool_state.is_drawing { // Selection tools use is_drawing to track drag state but never // start a brush stroke — skip end_stroke / commit_pending_history // so they don't push a bogus undo entry that empties the canvas. let is_selection_tool = matches!( self.tool_state.active_tool, Tool::Lasso | Tool::PolygonSelect | Tool::Select | Tool::VisionSelect | Tool::MagicWand | Tool::SmartSelect ); if !is_selection_tool { 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.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); } // end if !is_selection_tool } // End selection drag if self.tool_state.active_tool == Tool::Select { return Task::perform(async {}, |_| Message::SelectionDragEnd); } // End vector draw (all shape tools) if is_vector_shape_tool(self.tool_state.active_tool) { return Task::perform(async {}, |_| Message::VectorDrawEnd); } // End crop drag if self.tool_state.active_tool == Tool::Crop { return Task::perform(async {}, |_| Message::CropDragEnd); } // End vector select drag if self.tool_state.active_tool == Tool::VectorSelect && self.vector_drag_last.is_some() { return Task::perform(async {}, |_| Message::VectorSelectDragEnd); } } Message::CanvasPointerRightClicked { x: _, y: _, screen_x, screen_y, } => { // Ignore canvas_x, canvas_y for now, just pop the menu self.canvas_context_menu = Some((screen_x, screen_y)); } Message::CanvasContextMenuClose => { self.canvas_context_menu = None; } 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::FitToArea => { let doc = &mut self.documents[self.active_doc]; let fit_x = (doc.pane_size.0 - 24.0) / doc.engine.canvas_width() as f32; let fit_y = (doc.pane_size.1 - 24.0) / doc.engine.canvas_height() as f32; doc.zoom = fit_x.min(fit_y).clamp(ZOOM_MIN, ZOOM_MAX); doc.pan_offset = Vector::ZERO; } Message::CanvasSize((w, h)) => { self.documents[self.active_doc].pane_size = (w, h); // Feed the canvas pane size to the tablet state so evdev // absolute-axis position mapping can resolve to canvas coords. if let Ok(mut ts) = self.tablet_state.lock() { ts.set_screen_size(w.max(1.0), h.max(1.0)); } // 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 => { let doc = &mut self.documents[self.active_doc]; let current_marker = doc.engine.history_current(); if current_marker == doc.selection_history_marker && !doc.selection_history.is_empty() { // Undo selection if let Some(prev_mask) = doc.selection_history.pop() { if let Some(mask) = prev_mask { doc.engine.set_selection_mask(mask); } else { doc.engine.selection_clear(); } doc.selection_mask_dirty.set(true); } } else { doc.engine.undo(); doc.selection_history.clear(); } } Message::Redo => { self.documents[self.active_doc].engine.redo(); self.documents[self.active_doc].selection_history.clear(); } _ => {} } self.refresh_composite_if_needed(); } // ── Layer operations ────────────────────────── Message::LayerSelect(id) => { if self.preview_baseline.is_some() { self.restore_preview(); self.filter_preview_active = false; self.selected_filter = None; self.filter_params = serde_json::json!({}); self.refresh_composite_if_needed(); } let doc = &mut self.documents[self.active_doc]; if doc.engine.active_layer_id() != id { doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; self.bool_shape_a = None; self.bool_shape_b = None; 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.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerDelete(id) => { self.documents[self.active_doc].engine.delete_layer(id); self.documents[self.active_doc].modified = true; 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.documents[self.active_doc].modified = true; 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); self.documents[self.active_doc].modified = true; } Message::LayerSetOpacity(id, opacity) => { self.documents[self.active_doc] .engine .set_layer_opacity(id, opacity); self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerMergeDown => { self.documents[self.active_doc].engine.merge_down(); self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerFlatten => { self.documents[self.active_doc].engine.flatten_image(); self.documents[self.active_doc].modified = true; 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.documents[self.active_doc].modified = true; 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.documents[self.active_doc].modified = true; 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.documents[self.active_doc].modified = true; 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(); let active_id = self.documents[self.active_doc].engine.active_layer_id(); let active_is_group = self.documents[self.active_doc] .engine .get_layer_info(active_id) .is_some_and(|layer| layer.layer_type == hcie_engine_api::LayerType::Group); let group_id = self.documents[self.active_doc] .engine .add_group(&format!("Group {}", count + 1)); if active_id != 0 && active_id != group_id && !active_is_group { self.documents[self.active_doc] .engine .set_layer_parent(active_id, Some(group_id)); } self.documents[self.active_doc] .engine .set_active_layer(group_id); self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerToggleCollapse(id) => { self.documents[self.active_doc] .engine .toggle_group_collapsed(id); self.documents[self.active_doc].modified = true; } // ── 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 ───────────────────────────────── // Text tool handlers (TextSizeChanged, TextColorChanged, TextAlignChanged, // TextAccept, TextCancel, plus full editor handlers) are defined in the // brush/tool-options section below. // ── Layer styles ────────────────────────────── Message::LayerStyleToggle(index) => { if let Some(baseline) = self.layer_style_baseline.as_mut() { baseline.changed = true; } // 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 style: // - Style doesn't exist → create default style with enabled: true // - Style exists → clone and toggle enabled flag let new_style = match existing { None => { match disc { "DropShadow" => LayerStyle::DropShadow { enabled: true, 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: true, 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: true, opacity: 0.75, spread: 0.0, size: 5.0, color: [255, 255, 190, 255], blend_mode: "Screen".to_string(), }, "InnerGlow" => LayerStyle::InnerGlow { enabled: true, opacity: 0.75, spread: 0.0, size: 5.0, color: [255, 255, 190, 255], blend_mode: "Screen".to_string(), }, "BevelEmboss" => LayerStyle::BevelEmboss { enabled: true, 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], contour: "Linear".to_string(), }, "Satin" => LayerStyle::Satin { enabled: true, opacity: 0.5, angle: 120.0, distance: 11.0, size: 14.0, color: [0, 0, 0, 255], invert: true, }, "ColorOverlay" => LayerStyle::ColorOverlay { enabled: true, opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(), }, "GradientOverlay" => LayerStyle::GradientOverlay { enabled: true, opacity: 1.0, blend_mode: "Normal".to_string(), angle: 90.0, scale: 1.0, gradient_type: 0, }, "PatternOverlay" => LayerStyle::PatternOverlay { enabled: true, opacity: 1.0, blend_mode: "Normal".to_string(), scale: 1.0, pattern_name: "".to_string(), }, "Stroke" => LayerStyle::Stroke { enabled: true, size: 3.0, position: "Outside".to_string(), opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(), }, _ => unreachable!(), } } Some(s) => { let mut cloned = s.clone(); match &mut cloned { 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 = !*enabled; } } cloned } }; 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.documents[self.active_doc].modified = true; self.layer_style_baseline = None; self.active_dialog = ActiveDialog::None; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleCancel => { if let Some(baseline) = self.layer_style_baseline.take() { if baseline.changed && baseline.document_index < self.documents.len() { let doc = &mut self.documents[baseline.document_index]; for index in 0..10 { doc.engine .remove_layer_style_by_index(baseline.layer_id, index); } for style in baseline.styles { doc.engine.update_layer_style(baseline.layer_id, style); } doc.modified = baseline.was_modified; self.active_doc = baseline.document_index; self.refresh_composite_if_needed(); } } self.active_dialog = ActiveDialog::None; } Message::LayerStyleAdd => { log::warn!("LayerStyleAdd is unavailable; select one of the ten explicit styles"); } Message::LayerStyleUpdateParam(index, param, value) => { if let Some(baseline) = self.layer_style_baseline.as_mut() { baseline.changed = true; } 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::BevelEmboss { highlight_opacity, shadow_opacity, .. }, "opacity", ) => { *highlight_opacity = value; *shadow_opacity = value; } ( LayerStyle::BevelEmboss { highlight_opacity, .. }, "highlight_opacity", ) => { *highlight_opacity = value; } ( LayerStyle::BevelEmboss { shadow_opacity, .. }, "shadow_opacity", ) => { *shadow_opacity = 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) => { if let Some(baseline) = self.layer_style_baseline.as_mut() { baseline.changed = true; } 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, LayerStyle::BevelEmboss { highlight_blend_mode, .. } => *highlight_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) => { if let Some(baseline) = self.layer_style_baseline.as_mut() { baseline.changed = true; } 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, shadow_color, .. } => { if self.style_color_is_shadow { *shadow_color = new_color; } else { *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::LayerStyleUpdateString(index, param, value) => { if let Some(baseline) = self.layer_style_baseline.as_mut() { baseline.changed = true; } 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), ("BevelEmboss", LayerStyle::BevelEmboss { .. }) | ("DropShadow", LayerStyle::DropShadow { .. }) | ("InnerShadow", LayerStyle::InnerShadow { .. }) ) }); if let Some(style) = existing { let mut new_style = style.clone(); match (&mut new_style, param.as_str()) { (LayerStyle::BevelEmboss { style, .. }, "style") => *style = value, (LayerStyle::BevelEmboss { technique, .. }, "technique") => { *technique = value } (LayerStyle::BevelEmboss { direction, .. }, "direction") => { *direction = value } ( LayerStyle::BevelEmboss { highlight_blend_mode, .. }, "highlight_blend", ) => *highlight_blend_mode = value, ( LayerStyle::BevelEmboss { shadow_blend_mode, .. }, "shadow_blend", ) => *shadow_blend_mode = value, (LayerStyle::BevelEmboss { contour, .. }, "contour") => { *contour = 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::LayerStyleUpdateShadowColor(index, new_color) => { if let Some(baseline) = self.layer_style_baseline.as_mut() { baseline.changed = true; } 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), ("BevelEmboss", LayerStyle::BevelEmboss { .. })) }); if let Some(style) = existing { let mut updated_style = style.clone(); if let LayerStyle::BevelEmboss { shadow_color, .. } = &mut updated_style { *shadow_color = new_color; } self.documents[self.active_doc] .engine .update_layer_style(layer_id, updated_style); } } } 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::LayerStyleUpdateShadowBlend(index, mode) => { if let Some(baseline) = self.layer_style_baseline.as_mut() { baseline.changed = true; } 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), ("BevelEmboss", LayerStyle::BevelEmboss { .. })) }); if let Some(style) = existing { let mut updated_style = style.clone(); if let LayerStyle::BevelEmboss { shadow_blend_mode, .. } = &mut updated_style { *shadow_blend_mode = mode; } self.documents[self.active_doc] .engine .update_layer_style(layer_id, updated_style); } } } self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateShadowOpacity(index, value) => { if let Some(baseline) = self.layer_style_baseline.as_mut() { baseline.changed = true; } 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), ("BevelEmboss", LayerStyle::BevelEmboss { .. })) }); if let Some(style) = existing { let mut updated_style = style.clone(); if let LayerStyle::BevelEmboss { shadow_opacity, .. } = &mut updated_style { *shadow_opacity = value; } self.documents[self.active_doc] .engine .update_layer_style(layer_id, updated_style); } } } self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::ShowStyleShadowColorPicker(idx) => { self.show_style_color_picker = true; self.style_color_picker_idx = idx; self.style_color_is_shadow = true; 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 disc = disc_names[idx]; let styles = self.documents[self.active_doc] .engine .get_layer_styles(layer_id); if let Some(style) = styles.iter().find(|s| { matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. })) }) { let color = if let LayerStyle::BevelEmboss { shadow_color, .. } = style { *shadow_color } else { [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); } } return Task::none(); } Message::ShowStyleColorPicker(idx) => { self.show_style_color_picker = true; self.style_color_picker_idx = idx; self.style_color_is_shadow = false; // 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 => { let document_index = self.active_doc; let layer_id = self.documents[document_index].engine.active_layer_id(); self.layer_style_baseline = Some(LayerStyleBaseline { document_index, layer_id, styles: self.documents[document_index] .engine .get_layer_styles(layer_id), was_modified: self.documents[document_index].modified, changed: false, }); 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) | Message::DockPointerMoved(x, y) => { self.last_cursor_pos = Some((x, y)); let pointer = iced::Point::new(x, y); if self.dock.drag.is_none() { if let Some(header) = self.dock.header_drag { if pointer.distance(header.origin) >= 4.0 { self.dock.drag = Some(DockDragState { source_pane: header.source_pane, source_type: header.source_type, pointer: Some(pointer), candidate_target: None, accepted_preview: None, }); } } } if self.dock.drag.is_some() { let geometry = crate::dock::preview::DockGeometry { viewport: iced::Size::new( self.settings.window.width, self.settings.window.height, ), toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 }, }; let regions = self.dock.pane_grid.layout().pane_regions( crate::dock::preview::PANE_SPACING, geometry.grid_bounds().size(), ); let source_type = self.dock.drag.map(|drag| drag.source_type); let approved = crate::dock::preview::approved_target( pointer, geometry, ®ions, ) .filter(|target| { source_type.is_some_and(|source| { crate::dock::manager::DockDropPolicy::accepts(source, target.zone) }) }); if let Some(drag) = &mut self.dock.drag { drag.pointer = Some(pointer); drag.candidate_target = approved.map(|target| target.target); drag.accepted_preview = approved.map(|target| target.preview); } } if let Some(floating_drag) = self.dock.floating_drag { let geometry = crate::dock::preview::DockGeometry { viewport: iced::Size::new( self.settings.window.width, self.settings.window.height, ), toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 }, }; let regions = self.dock.pane_grid.layout().pane_regions( crate::dock::preview::PANE_SPACING, geometry.grid_bounds().size(), ); self.dock.floating_target = crate::dock::preview::approved_target( pointer, geometry, ®ions, ) .filter(|target| { crate::dock::manager::DockDropPolicy::accepts( floating_drag.panel, target.zone, ) }); if let Some(current) = self .dock .floating .iter() .find(|item| item.panel == floating_drag.panel) .map(|item| item.rect) { let rect = crate::dock::manager::DockRect { x: x - floating_drag.pointer_offset.x, y: y - floating_drag.pointer_offset.y, ..current }; self.dock.move_floating( floating_drag.panel, rect, (self.settings.window.width, self.settings.window.height), ); } } 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 | Message::DockPointerReleased => { self.layer_style_dragging = false; self.layer_style_drag_start = None; let mut persist_dock = self.dock.resize_persistence_dirty; self.dock.resize_persistence_dirty = false; if let Some(header) = self.dock.header_drag.take() { if let Some(drag) = self .dock .drag .take() .filter(|drag| drag.source_pane == header.source_pane) { if let Some(target) = drag.candidate_target { if self.dock.apply_drop(drag.source_pane, target) { persist_dock = true; } } } } if let Some(drag) = self.dock.floating_drag.take() { if let Some(target) = self.dock.floating_target.take() { self.dock.redock_floating_at(drag.panel, target.target); } persist_dock = true; } if persist_dock { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } return Task::perform(async {}, |_| Message::DockReleaseCleanup); } Message::DockReleaseCleanup => { self.dock.cancel_transient_drags(); } // ── 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; self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } Message::BrushSizeRelative(delta) => { self.tool_state.brush_size = (self.tool_state.brush_size + delta).clamp(1.0, 500.0); self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } Message::BrushOpacityChanged(opacity) => { self.tool_state.brush_opacity = opacity; self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } Message::BrushHardnessChanged(hardness) => { self.tool_state.brush_hardness = hardness; self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } Message::BrushFlowChanged(flow) => { self.settings.tool_settings.brush_flow = flow; let _ = self.settings.save(); } Message::BrushSpacingChanged(spacing) => { self.tool_state.brush_spacing = spacing; self.settings.tool_settings.brush_spacing = spacing; let _ = self.settings.save(); } Message::SelectionToleranceChanged(tol) => { self.settings.tool_settings.magic_wand_tolerance = tol as f32; self.settings.tool_settings.flood_fill_tolerance = tol as f32; let _ = self.settings.save(); } Message::SelectionContiguousToggled(v) => { self.settings.tool_settings.selection_contiguous = v; let _ = self.settings.save(); } Message::SelectionAntiAliasToggled(v) => { self.settings.tool_settings.selection_anti_alias = v; let _ = self.settings.save(); } Message::SelectionFeatherChanged(r) => { self.settings.tool_settings.select_feather = r; let _ = self.settings.save(); } Message::SprayParticleSizeChanged(s) => { self.settings.tool_settings.spray_particle_size = s; let _ = self.settings.save(); } Message::SprayDensityChanged(d) => { self.settings.tool_settings.spray_density = d; let _ = self.settings.save(); } Message::GradientTypeChanged(t) => { self.settings.tool_settings.gradient_type = t; let _ = self.settings.save(); } Message::VectorStrokeChanged(s) => { self.settings.tool_settings.vector_stroke_width = s; crate::shape_sync::sync_shape_from_tool(self); let _ = self.settings.save(); } Message::VectorOpacityChanged(o) => { self.settings.tool_settings.vector_opacity = o; crate::shape_sync::sync_shape_from_tool(self); let _ = self.settings.save(); } Message::VectorFillToggled(f) => { self.settings.tool_settings.vector_fill = f; crate::shape_sync::sync_shape_from_tool(self); let _ = self.settings.save(); } Message::VectorRadiusChanged(r) => { self.settings.tool_settings.vector_radius = r; crate::shape_sync::sync_shape_from_tool(self); let _ = self.settings.save(); } Message::VectorPointsChanged(p) => { self.settings.tool_settings.vector_points = p; crate::shape_sync::sync_shape_from_tool(self); let _ = self.settings.save(); } Message::VectorSidesChanged(s) => { self.settings.tool_settings.vector_sides = s; crate::shape_sync::sync_shape_from_tool(self); let _ = self.settings.save(); } Message::TextFontChanged(font) => { self.settings.tool_settings.text_font = font.clone(); if let Some(draft) = self.active_document_mut().text_draft.as_mut() { draft.font = font; } let _ = self.settings.save(); } Message::TextSizeChanged(size) => { self.settings.tool_settings.text_size = size; if let Some(draft) = self.active_document_mut().text_draft.as_mut() { draft.size = size; } let _ = self.settings.save(); } Message::TextAngleChanged(angle) => { self.settings.tool_settings.text_angle = angle; if let Some(draft) = self.active_document_mut().text_draft.as_mut() { draft.angle = angle; } let _ = self.settings.save(); } Message::TextColorChanged(color) => { if let Some(draft) = self.active_document_mut().text_draft.as_mut() { draft.color = color; } } Message::TextAlignChanged(alignment) => { if let Some(draft) = self.active_document_mut().text_draft.as_mut() { draft.alignment = match alignment.as_str() { "Center" => hcie_engine_api::TextAlignment::Center, "Right" => hcie_engine_api::TextAlignment::Right, "Justify" => hcie_engine_api::TextAlignment::Justify, _ => hcie_engine_api::TextAlignment::Left, }; } } Message::TextOrientationChanged(orientation) => { if let Some(draft) = self.active_document_mut().text_draft.as_mut() { draft.orientation = match orientation.as_str() { "Vertical" => hcie_engine_api::TextOrientation::Vertical, _ => hcie_engine_api::TextOrientation::Horizontal, }; } } Message::TextContentChanged(content) => { if let Some(draft) = self.active_document_mut().text_draft.as_mut() { draft.editor = iced::widget::text_editor::Content::with_text(&content); draft.content = content; } } Message::TextEditorAction(action) => { if let Some(draft) = self.active_document_mut().text_draft.as_mut() { draft.editor.perform(action); draft.content = draft.editor.text(); } } Message::TextCommit => { let draft = self.active_document_mut().text_draft.take(); if let Some(draft) = draft { if !draft.content.is_empty() { let engine = &mut self.active_document_mut().engine; engine.add_text( &draft.content, &draft.font, draft.size, draft.x, draft.y, draft.color, draft.angle, draft.alignment, draft.orientation, &[], ); self.active_document_mut().modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } } Message::TextAccept => { // Alias of TextCommit for existing panel wiring. return self.update(Message::TextCommit); } Message::TextCancel => { self.active_document_mut().text_draft = None; } Message::ToolApplyToNewLayerToggled(v) => { self.settings.tool_settings.tool_apply_to_new_layer = v; let _ = self.settings.save(); } Message::ResetToolDefaults => { self.settings.reset_tool_defaults(); self.settings.apply_to_tool_state(&mut self.tool_state); let _ = self.settings.save(); } Message::BrushImportAbr => { log::info!("Import ABR brush requested"); let task = Task::perform( async { let result = rfd::AsyncFileDialog::new() .add_filter("ABR Brush Files", &["abr"]) .add_filter("All Files", &["*"]) .set_title("Import ABR Brush") .pick_file() .await .map(|handle| handle.path().to_path_buf()); match result { Some(path) => { let file_name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); match std::fs::read(&path) { Ok(data) => { let presets = crate::brush_import::import_abr_with_prefix( &data, &file_name, ); Ok(presets) } Err(e) => Err(format!("Failed to read file: {}", e)), } } None => Err("No file selected".to_string()), } }, |result| Message::BrushImportAbrFile(result), ); return task; } Message::BrushImportAbrFile(result) => { match result { Ok(presets) => { log::info!("Imported {} brush presets", presets.len()); // Store imported presets in the tool state self.tool_state.imported_brushes.extend(presets); } Err(e) => { log::error!("Failed to import ABR brush: {}", e); } } } // ── Filters ─────────────────────────────────── Message::FilterSelect(filter) => { self.restore_preview(); self.selected_filter = Some(filter); self.filter_preview_active = self.documents[self.active_doc] .engine .active_layer_is_editable() && self.begin_preview(); self.filter_params = serde_json::json!({}); } Message::FilterParamChanged(key, value) => { if let serde_json::Value::Object(ref mut map) = self.filter_params { if let serde_json::Value::Object(values) = value { map.extend(values); } else { map.insert(key, value); } } if self.filter_preview_active && self.reset_preview_to_baseline() { let Some(filter) = self.selected_filter else { return Task::none(); }; 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 { if !self.documents[self.active_doc] .engine .active_layer_is_editable() { log::warn!("filter apply blocked for locked, group, or mask layer"); return Task::none(); } self.reset_preview_to_baseline(); let filter_id = filter_type_to_id(filter); self.documents[self.active_doc] .engine .apply_filter(filter_id, self.filter_params.clone()); self.preview_baseline = None; self.documents[self.active_doc].modified = true; 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 => { self.filter_params = serde_json::json!({}); if self.filter_preview_active && self.reset_preview_to_baseline() { if let Some(filter) = self.selected_filter { self.documents[self.active_doc].engine.apply_filter_preview( filter_type_to_id(filter), self.filter_params.clone(), ); } self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::FilterPreviewToggle(enabled) => { self.filter_preview_active = enabled; if enabled { if self.begin_preview() { let Some(filter) = self.selected_filter else { return Task::none(); }; 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); } } else { self.restore_preview(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::FilterPreviewApply => { return self.update(Message::FilterApply); } Message::FilterPreviewRestore => { if self.filter_preview_active { self.restore_preview(); 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); } } // ── Dialogs ─────────────────────────────────── Message::DialogOpen(dialog) => { if matches!( dialog, ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation ) { self.dialog_brightness = 0.0; self.dialog_contrast = 0.0; self.dialog_hue = 0.0; self.dialog_saturation = 0.0; self.dialog_lightness = 0.0; self.begin_preview(); } self.active_dialog = dialog; } Message::DialogClose => { if matches!( self.active_dialog, ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation ) && self.restore_preview() { self.refresh_composite_if_needed(); } self.active_dialog = ActiveDialog::None; self.pending_document_close = None; } Message::DialogCloseSave => { if let Some(path) = self.documents[self.active_doc].source_path.clone() { match self.save_document_to(self.active_doc, &path) { Ok(()) => { self.finish_successful_save(self.active_doc, path); return self.continue_close_after_save(); } Err(error) => { log::error!("Failed to save before close: {error}"); return Task::none(); } } } self.pending_file_operation = Some(PendingFileOperation::SaveAs { close_after: true }); self.active_dialog = ActiveDialog::None; return self.update(Message::SaveFileAs); } Message::DialogCloseDiscard => { self.documents[self.active_doc].modified = false; return self.continue_close_after_save(); } // ── 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(); let doc = 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), selection_transform: None, transform_drag_handle: TransformHandle::None, transform_drag_start: None, transform_original: None, transform_source: None, transform_layer_baseline: None, transform_selection_baseline: None, transform_preview_base: None, internal_clipboard: None, selection_mask: None, selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, selection_history: Vec::new(), selection_history_marker: 0, crop_state: CropState::default(), lasso_points: Vec::new(), polygon_points: Vec::new(), gradient_drag: None, vision_rect: None, text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, selection_fill_spans: None, selection_mode: selection::SelectionMode::Replace, saved_selection_mask: None, quick_mask: false, selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, }; if self.show_welcome && self.documents.len() == 1 { // Replace the placeholder empty document created for the // welcome screen instead of appending a second tab. self.documents = vec![doc]; self.active_doc = 0; } else { self.documents.push(doc); self.active_doc = self.documents.len() - 1; } self.show_welcome = false; 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.reset_preview_to_baseline(); 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.reset_preview_to_baseline(); 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": 1.0 + self.dialog_saturation / 100.0, "lightness": self.dialog_lightness}); self.reset_preview_to_baseline(); 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": 1.0 + v / 100.0, "lightness": self.dialog_lightness}); self.reset_preview_to_baseline(); 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": 1.0 + self.dialog_saturation / 100.0, "lightness": v}); self.reset_preview_to_baseline(); 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": 1.0 + self.dialog_saturation / 100.0, "lightness": self.dialog_lightness}) } _ => serde_json::json!({}), }; self.reset_preview_to_baseline(); 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.preview_baseline = None; self.documents[self.active_doc].modified = true; } 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), "Border" => self.documents[self.active_doc].engine.selection_border(v), "Smooth" => self.documents[self.active_doc].engine.selection_smooth(v), _ => {} }, _ => {} } // Selection operations mutate the existing mask in place, so force overlay cache // synchronization before the next marching-ants frame. self.refresh_composite_if_needed(); self.active_dialog = ActiveDialog::None; return Task::perform(async {}, |_| Message::CompositeRefresh); } // ── Image Size Dialog ──────────────────────── Message::DialogImageSizeWidth(w) => { self.dialog_image_size_width = w; if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 { self.dialog_image_size_height = (w as f32 / self.dialog_image_size_original_ratio) as u32; } } Message::DialogImageSizeHeight(h) => { self.dialog_image_size_height = h; if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 { self.dialog_image_size_width = (h as f32 * self.dialog_image_size_original_ratio) as u32; } } Message::DialogImageSizeConstrain(c) => { self.dialog_image_size_constrain = c; } Message::DialogImageSizeApply => { let w = self.dialog_image_size_width.max(1); let h = self.dialog_image_size_height.max(1); self.documents[self.active_doc].engine.resize_canvas(w, h); self.documents[self.active_doc].engine.pre_tile_all_layers(); self.refresh_composite_if_needed(); self.documents[self.active_doc].full_upload.replace(true); self.active_dialog = ActiveDialog::None; return Task::perform(async {}, |_| Message::CompositeRefresh); } // ── Canvas Size Dialog ─────────────────────── Message::DialogCanvasSizeWidth(w) => { self.dialog_canvas_size_width = w; } Message::DialogCanvasSizeHeight(h) => { self.dialog_canvas_size_height = h; } Message::DialogCanvasSizeAnchor(r, c) => { self.dialog_canvas_size_anchor = (r, c); } Message::DialogCanvasSizeApply => { let new_w = self.dialog_canvas_size_width.max(1); let new_h = self.dialog_canvas_size_height.max(1); self.documents[self.active_doc] .engine .resize_canvas(new_w, new_h); self.documents[self.active_doc].engine.pre_tile_all_layers(); self.refresh_composite_if_needed(); self.documents[self.active_doc].full_upload.replace(true); 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 { 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 { let previous = self.documents[self.active_doc] .engine .get_selection_mask() .map(ToOwned::to_owned); self.documents[self.active_doc] .engine .create_selection_rect(sx, sy, ex, ey); self.combine_current_selection(previous); // Store selection bounds for marching ants display self.documents[self.active_doc].selection_bounds = Some((sx, sy, ex - sx, ey - sy)); // Clear the drag-preview rect so the blue rectangle // disappears and the marching ants overlay takes over. self.documents[self.active_doc].selection_rect = None; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } // Clear selection rect if no valid selection was made self.documents[self.active_doc].selection_rect = None; } Message::SelectAll => { let previous = self.documents[self.active_doc].engine.get_selection_mask().map(ToOwned::to_owned); let current_marker = self.documents[self.active_doc].engine.history_current(); if current_marker != self.documents[self.active_doc].selection_history_marker { self.documents[self.active_doc].selection_history.clear(); self.documents[self.active_doc].selection_history_marker = current_marker; } self.documents[self.active_doc].selection_history.push(previous); let w = self.documents[self.active_doc].engine.canvas_width(); let h = self.documents[self.active_doc].engine.canvas_height(); self.documents[self.active_doc].engine.selection_all(); self.documents[self.active_doc].selection_bounds = Some((0, 0, w, h)); } Message::Deselect => { let doc = &mut self.documents[self.active_doc]; let previous = doc.engine.get_selection_mask().map(ToOwned::to_owned); let current_marker = doc.engine.history_current(); if current_marker != doc.selection_history_marker { doc.selection_history.clear(); doc.selection_history_marker = current_marker; } doc.selection_history.push(previous); doc.selection_rect = None; doc.selection_bounds = None; doc.selection_transform = None; doc.transform_drag_handle = TransformHandle::None; doc.engine.selection_clear(); doc.selection_mask = None; doc.selection_mask_dirty.set(true); doc.selection_fill_spans = None; doc.marching_ants_edges = None; return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::SelectInverse => { self.documents[self.active_doc].engine.selection_invert(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::SelectionModeChanged(mode) => { self.documents[self.active_doc].selection_mode = mode; } Message::SelectionSave => { self.documents[self.active_doc].saved_selection_mask = self.documents [self.active_doc] .engine .get_selection_mask() .map(ToOwned::to_owned); } Message::SelectionLoad => { if let Some(mask) = self.documents[self.active_doc].saved_selection_mask.clone() { self.documents[self.active_doc] .engine .set_selection_mask(mask); self.refresh_composite_if_needed(); } } Message::QuickMaskToggle => { let doc = &mut self.documents[self.active_doc]; doc.quick_mask = !doc.quick_mask; } Message::ClearPixels => { self.documents[self.active_doc] .engine .clear_selection_pixels(); self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); } // ── Selection transform ──────────────────────── Message::TransformDragStart(x, y) => { let doc = &mut self.documents[self.active_doc]; if let Some(ref tr) = doc.selection_transform { let original = tr.clone(); doc.transform_drag_start = Some((x, y)); doc.transform_original = Some(original); } } Message::TransformDragMove(x, y) => { let doc = &mut self.documents[self.active_doc]; let canvas_w = doc.engine.canvas_width(); let canvas_h = doc.engine.canvas_height(); let previous_preview_bounds = doc .selection_transform .as_ref() .and_then(|tr| preview_bounds(tr, canvas_w, canvas_h)); if let (Some(start), Some(ref mut tr)) = (doc.transform_drag_start, &mut doc.selection_transform) { let dx = x - start.0; let dy = y - start.1; match doc.transform_drag_handle { TransformHandle::Move => { tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y + dy); doc.transform_drag_start = Some((x, y)); } TransformHandle::TopLeft | TransformHandle::TopRight | TransformHandle::BottomLeft | TransformHandle::BottomRight | TransformHandle::Top | TransformHandle::Bottom | TransformHandle::Left | TransformHandle::Right => { tr.resize_from_delta(doc.transform_drag_handle, dx, dy); doc.transform_drag_start = Some((x, y)); } TransformHandle::Rotate => { if let Some(original) = doc.transform_original.as_ref() { tr.rotation = selection::transform::rotation_from_drag( original, start, (x, y), ); } } TransformHandle::None => {} } tr.sanitize_geometry(self.modifiers.shift()); } let doc = &mut self.documents[self.active_doc]; render_transform_preview(doc, previous_preview_bounds); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::TransformDragEnd => { let doc = &mut self.documents[self.active_doc]; doc.transform_drag_start = None; doc.transform_original = None; } Message::TransformApply => { let tr_taken = self.documents[self.active_doc].selection_transform.take(); if let Some(tr) = tr_taken { let doc = &mut self.documents[self.active_doc]; let canvas_w = doc.engine.canvas_width(); let canvas_h = doc.engine.canvas_height(); let was_move = doc.transform_source.is_some(); let before_pixels = doc .transform_layer_baseline .take() .or_else(|| doc.engine.get_active_layer_pixels()); let Some(before_pixels) = before_pixels else { return Task::none(); }; apply_transform_to_layer( &mut doc.engine, &tr, canvas_w, canvas_h, before_pixels, if was_move { "Move Selection" } else { "Transform Apply" }, ); doc.engine.selection_clear(); doc.selection_bounds = None; doc.selection_mask = None; doc.selection_mask_dirty.set(true); doc.transform_drag_handle = TransformHandle::None; doc.transform_drag_start = None; doc.transform_original = None; doc.transform_source = None; doc.transform_selection_baseline = None; doc.transform_preview_base = None; doc.modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::TransformCancel => { let doc = &mut self.documents[self.active_doc]; log::debug!( "[TransformCancel] begin restore_layer={}, restore_selection={}", doc.transform_source.is_some() && doc.transform_layer_baseline.is_some(), doc.transform_selection_baseline.is_some() ); let original_layer = doc.transform_layer_baseline.take(); if doc.transform_source.is_some() { if let Some(original_layer) = original_layer { doc.engine.set_active_layer_pixels(original_layer); } } if let Some(mask) = doc.transform_selection_baseline.take() { doc.engine.set_selection_mask(mask.clone()); doc.selection_mask = Some(mask); doc.selection_mask_dirty.set(true); } doc.selection_transform = None; doc.transform_source = None; doc.transform_original = None; doc.transform_drag_start = None; doc.transform_drag_handle = TransformHandle::None; doc.transform_preview_base = None; self.refresh_composite_if_needed(); log::debug!("[TransformCancel] end without history snapshot"); } // ── 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() { // `add_vector_shape` safely auto-creates a vector layer from a non-vector // layer, but would otherwise append to a locked active vector layer. let active_id = self.documents[self.active_doc].engine.active_layer_id(); let active_is_locked_vector = self.documents[self.active_doc] .engine .get_layer_info(active_id) .map(|info| { info.locked && info.layer_type == hcie_engine_api::LayerType::Vector }) .unwrap_or(false); if active_is_locked_vector { let safe_source = self.documents[self.active_doc] .engine .layer_infos() .into_iter() .find(|info| { !info.locked && !matches!( info.layer_type, hcie_engine_api::LayerType::Vector | hcie_engine_api::LayerType::Group | hcie_engine_api::LayerType::Mask ) }) .map(|info| info.id); let Some(safe_source) = safe_source else { return Task::none(); }; self.documents[self.active_doc] .engine .set_active_layer(safe_source); } let engine = &mut self.documents[self.active_doc].engine; let color = self.fg_color; let stroke = self.settings.tool_settings.vector_stroke_width; let fill = self.settings.tool_settings.vector_fill; let opacity = self.settings.tool_settings.vector_opacity; let radius = self.settings.tool_settings.vector_radius; let points = self.settings.tool_settings.vector_points; let sides = self.settings.tool_settings.vector_sides; let fill_color = color; let shape = match self.tool_state.active_tool { Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, radius, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorCircle => Some(hcie_engine_api::VectorShape::Circle { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorLine => Some(hcie_engine_api::VectorShape::Line { x1: x0, y1: y0, x2: x1, y2: y1, stroke, color, angle: 0.0, cap_start: hcie_engine_api::LineCap::Round, cap_end: hcie_engine_api::LineCap::Round, opacity, hardness: 0.5, }), Tool::VectorArrow => Some(hcie_engine_api::VectorShape::Arrow { x1: x0, y1: y0, x2: x1, y2: y1, stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, thick: false, }), Tool::VectorStar => Some(hcie_engine_api::VectorShape::Star { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, points, inner_radius: 0.5, }), Tool::VectorPolygon => Some(hcie_engine_api::VectorShape::Polygon { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, sides, }), Tool::VectorRhombus => Some(hcie_engine_api::VectorShape::Rhombus { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorCylinder => Some(hcie_engine_api::VectorShape::Cylinder { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorHeart => Some(hcie_engine_api::VectorShape::Heart { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorBubble => Some(hcie_engine_api::VectorShape::Bubble { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorGear => Some(hcie_engine_api::VectorShape::Gear { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorCross => Some(hcie_engine_api::VectorShape::Cross { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorCrescent => Some(hcie_engine_api::VectorShape::Crescent { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorBolt => Some(hcie_engine_api::VectorShape::Bolt { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), Tool::VectorArrow4 => Some(hcie_engine_api::VectorShape::Arrow4 { x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, }), _ => None, }; if let Some(shape) = shape { engine.add_vector_shape(shape); self.documents[self.active_doc].modified = true; } self.refresh_composite_if_needed(); } } // ── Vector select / edit ───────────────────── Message::VectorSelectClick { x, y } => { use hcie_engine_api::VectorEditHandle as VEH; // If a shape is already selected, hit-test its edit handles first. if self.documents[self.active_doc] .selected_vector_shape .is_some() { let handle = self.vector_hit_test_handle(x, y); if handle != VEH::None && handle != VEH::Move { // Clicked on a resize/rotate handle — start drag with that handle. let doc = &mut self.documents[self.active_doc]; let layer_id = doc.engine.active_layer_id(); let shape_index = doc.selected_vector_shape.unwrap(); if let Some(original) = doc .engine .active_vector_shapes() .and_then(|shapes| shapes.get(shape_index).cloned()) { doc.vector_edit_handle = handle; self.vector_drag_last = Some((x, y)); self.vector_drag_session = Some(crate::vector_edit::VectorDragSession { layer_id, shape_index, handle, pointer_start: (x, y), original, }); } return Task::none(); } } // Find ALL shapes at the click position for cycling support. // Collect indices of shapes whose bounding box contains (x, y), // iterating forward so index 0 is the bottom-most shape. if let Some(shapes) = self.documents[self.active_doc] .engine .active_vector_shapes() { let hits: Vec = shapes .iter() .enumerate() .filter(|(_, s)| { let (l, t, r, b) = s.normalized_bounds(); x >= l && x <= r && y >= t && y <= b }) .map(|(i, _)| i) .collect(); if !hits.is_empty() { // Save snapshot before editing (for undo). let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if self.vector_shapes_snapshot.as_ref().map(|s| s.layer_id) != Some(layer_id) { self.vector_shapes_snapshot = Some(crate::vector_edit::VectorEditSnapshot { layer_id, shapes: shapes.clone(), }); } let doc = &mut self.documents[self.active_doc]; let currently_selected = doc.selected_vector_shape; doc.selected_vector_shape = crate::vector_edit::cycle_selection(&hits, currently_selected); doc.vector_cycle_index = doc .selected_vector_shape .and_then(|selected| hits.iter().position(|hit| *hit == selected)) .unwrap_or(0); doc.vector_draw = None; // clear any preview doc.vector_edit_handle = VEH::Move; self.vector_drag_last = Some((x, y)); if let Some(shape_index) = doc.selected_vector_shape { self.vector_drag_session = Some(crate::vector_edit::VectorDragSession { layer_id, shape_index, handle: VEH::Move, pointer_start: (x, y), original: shapes[shape_index].clone(), }); } crate::shape_sync::sync_tool_from_shape(self); } else { // No shape under cursor — deselect. let doc = &mut self.documents[self.active_doc]; doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = VEH::None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; } } else { // No vector shapes on this layer — deselect. let doc = &mut self.documents[self.active_doc]; doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = VEH::None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; } } Message::VectorSelectDragStart { x, y } => { if self.documents[self.active_doc] .selected_vector_shape .is_some() { // Hit-test vector edit handles to determine drag mode. let handle = self.vector_hit_test_handle(x, y); let doc = &mut self.documents[self.active_doc]; let layer_id = doc.engine.active_layer_id(); let shape_index = doc.selected_vector_shape.unwrap(); if let Some(original) = doc .engine .active_vector_shapes() .and_then(|shapes| shapes.get(shape_index).cloned()) { doc.vector_edit_handle = handle; self.vector_drag_last = Some((x, y)); self.vector_drag_session = Some(crate::vector_edit::VectorDragSession { layer_id, shape_index, handle, pointer_start: (x, y), original, }); } } } Message::VectorSelectDragMove { x, y } => { if let Some(session) = self.vector_drag_session.clone() { let active_layer = self.documents[self.active_doc].engine.active_layer_id(); if active_layer != session.layer_id || self.documents[self.active_doc].selected_vector_shape != Some(session.shape_index) { self.vector_drag_last = None; self.vector_drag_session = None; return Task::none(); } let shape = crate::vector_edit::transform_shape( &session, (x, y), self.modifiers.shift(), self.modifiers.alt(), ); let (x1, y1, x2, y2) = shape.normalized_bounds(); let angle = shape.angle(); if let Some(shapes) = self.documents[self.active_doc] .engine .get_layer_shapes_direct(session.layer_id) { if let Some(current) = shapes.get_mut(session.shape_index) { *current = shape; } } self.documents[self.active_doc].modified = true; self.vector_drag_last = Some((x, y)); let should_render = self .vector_drag_last_render .is_none_or(|last| last.elapsed() >= std::time::Duration::from_millis(16)); if should_render { let engine = &mut self.documents[self.active_doc].engine; engine.set_vector_shape_bounds( session.layer_id, session.shape_index, x1, y1, x2, y2, ); engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle); self.vector_drag_last_render = Some(std::time::Instant::now()); self.refresh_composite_if_needed(); } } } Message::VectorSelectDragEnd => { if self.vector_drag_last.is_some() { if let Some(session) = self.vector_drag_session.as_ref() { if let Some(shape) = self.documents[self.active_doc] .engine .active_vector_shapes() .and_then(|shapes| shapes.get(session.shape_index).cloned()) { let (x1, y1, x2, y2) = shape.normalized_bounds(); let angle = shape.angle(); let engine = &mut self.documents[self.active_doc].engine; engine.set_vector_shape_bounds( session.layer_id, session.shape_index, x1, y1, x2, y2, ); engine.set_vector_shape_angle( session.layer_id, session.shape_index, angle, ); } } // Commit the move/resize/rotate: if shapes changed, push history snapshot. self.vector_drag_last = None; self.vector_drag_last_render = None; self.vector_drag_session = None; self.documents[self.active_doc].vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.refresh_composite_if_needed(); } } Message::VectorShapeDelete => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc] .engine .delete_vector_shape(layer_id, idx); self.documents[self.active_doc].selected_vector_shape = None; self.documents[self.active_doc].modified = true; self.documents[self.active_doc].vector_cycle_index = 0; self.vector_shapes_snapshot = None; self.vector_drag_session = None; self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } else { return self.update(Message::Deselect); } } Message::VectorShapeSelect(idx) => { self.documents[self.active_doc].selected_vector_shape = idx; if idx.is_some() { self.documents[self.active_doc].vector_cycle_index = 0; let doc = &self.documents[self.active_doc]; if let Some(shapes) = doc.engine.active_vector_shapes() { self.vector_shapes_snapshot = Some(crate::vector_edit::VectorEditSnapshot { layer_id: doc.engine.active_layer_id(), shapes, }); } crate::shape_sync::sync_tool_from_shape(self); } else { self.documents[self.active_doc].vector_cycle_index = 0; self.vector_shapes_snapshot = None; } self.vector_drag_session = None; } // ── Vector shape apply / cancel / flip ────── Message::VectorShapeApply => { // Commit edits: clear snapshot and deselect. let doc = &mut self.documents[self.active_doc]; doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; } Message::VectorShapeCancel => { // Restore snapshot: put original shapes back, then deselect. if let Some(snapshot) = self.vector_shapes_snapshot.take() { let doc = &mut self.documents[self.active_doc]; if let Some(shapes) = doc.engine.get_layer_shapes_direct(snapshot.layer_id) { *shapes = snapshot.shapes; } doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.vector_drag_last = None; self.vector_drag_session = None; doc.engine.mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::VectorShapeFlipH => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if let Some(shapes) = self.documents[self.active_doc] .engine .get_layer_shapes_direct(layer_id) { if let Some(shape) = shapes.get_mut(idx) { match shape { hcie_engine_api::VectorShape::FreePath { pts, .. } => { if pts.is_empty() { return Task::none(); } let cx = pts.iter().map(|p| p[0]).sum::() / pts.len() as f32; for p in pts.iter_mut() { p[0] = 2.0 * cx - p[0]; } } _ => { let (x1, y1, x2, y2) = shape.bounds(); // Swap x1/x2 to flip horizontally. shape.set_bounds(x2, y1, x1, y2); } } self.documents[self.active_doc] .engine .mark_composite_dirty(); self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); } } } } Message::VectorShapeFlipV => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if let Some(shapes) = self.documents[self.active_doc] .engine .get_layer_shapes_direct(layer_id) { if let Some(shape) = shapes.get_mut(idx) { match shape { hcie_engine_api::VectorShape::FreePath { pts, .. } => { if pts.is_empty() { return Task::none(); } let cy = pts.iter().map(|p| p[1]).sum::() / pts.len() as f32; for p in pts.iter_mut() { p[1] = 2.0 * cy - p[1]; } } _ => { let (x1, y1, x2, y2) = shape.bounds(); // Swap y1/y2 to flip vertically. shape.set_bounds(x1, y2, x2, y1); } } self.documents[self.active_doc] .engine .mark_composite_dirty(); self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); } } } } // ── Geometry panel controls ─────────────────── Message::GeometryBoundsX1(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let shapes = self.documents[self.active_doc] .engine .active_vector_shapes() .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let (_, y1, x2, y2) = s.bounds(); self.documents[self.active_doc] .engine .set_vector_shape_bounds(layer_id, idx, v, y1, x2, y2); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } } Message::GeometryBoundsY1(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let shapes = self.documents[self.active_doc] .engine .active_vector_shapes() .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let (x1, _, x2, y2) = s.bounds(); self.documents[self.active_doc] .engine .set_vector_shape_bounds(layer_id, idx, x1, v, x2, y2); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } } Message::GeometryBoundsX2(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let shapes = self.documents[self.active_doc] .engine .active_vector_shapes() .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let (x1, y1, _, y2) = s.bounds(); self.documents[self.active_doc] .engine .set_vector_shape_bounds(layer_id, idx, x1, y1, v, y2); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } } Message::GeometryBoundsY2(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let shapes = self.documents[self.active_doc] .engine .active_vector_shapes() .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let (x1, y1, x2, _) = s.bounds(); self.documents[self.active_doc] .engine .set_vector_shape_bounds(layer_id, idx, x1, y1, x2, v); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } } Message::GeometryFillToggled(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc] .engine .set_vector_shape_fill(layer_id, idx, v); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryFillColorChanged(c) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc] .engine .set_vector_shape_fill_color(layer_id, idx, c); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryStrokeColorChanged(c) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc] .engine .set_vector_shape_color(layer_id, idx, c); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryOpacityChanged(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc] .engine .set_vector_shape_opacity(layer_id, idx, v); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryHardnessChanged(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc] .engine .set_vector_shape_hardness(layer_id, idx, v); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryBoolShapeA(idx) => { self.bool_shape_a = idx; } Message::GeometryBoolShapeB(idx) => { self.bool_shape_b = idx; } Message::GeometryBooleanOp(op) => { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if let (Some(a), Some(b)) = (self.bool_shape_a, self.bool_shape_b) { let shape_count = self.documents[self.active_doc] .engine .active_vector_shapes() .map_or(0, |shapes| shapes.len()); let valid = a < shape_count && b < shape_count && a != b; let succeeded = valid && self.documents[self.active_doc] .engine .boolean_vector_shapes(layer_id, op, a, b); if succeeded { let result_index = shape_count - 2; self.documents[self.active_doc].selected_vector_shape = Some(result_index); self.documents[self.active_doc].vector_cycle_index = 0; self.bool_shape_a = None; self.bool_shape_b = None; self.vector_shapes_snapshot = None; self.documents[self.active_doc].modified = true; self.documents[self.active_doc] .engine .mark_composite_dirty(); crate::shape_sync::sync_tool_from_shape(self); self.refresh_composite_if_needed(); } } } Message::GeometryLineCapStart(cap) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let shapes = self.documents[self.active_doc] .engine .active_vector_shapes() .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let ce = match s { hcie_engine_api::VectorShape::Line { cap_end, .. } => *cap_end, _ => hcie_engine_api::LineCap::Round, }; self.documents[self.active_doc] .engine .set_vector_shape_line_caps(layer_id, idx, cap, ce); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } } Message::GeometryLineCapEnd(cap) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let shapes = self.documents[self.active_doc] .engine .active_vector_shapes() .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let cs = match s { hcie_engine_api::VectorShape::Line { cap_start, .. } => *cap_start, _ => hcie_engine_api::LineCap::Round, }; self.documents[self.active_doc] .engine .set_vector_shape_line_caps(layer_id, idx, cs, cap); self.documents[self.active_doc].modified = true; crate::shape_sync::sync_tool_from_shape(self); self.documents[self.active_doc] .engine .mark_composite_dirty(); self.refresh_composite_if_needed(); } } } // ── Lasso / polygon selection ───────────────── Message::LassoDragMove(x, y) => { let cx = x.round().max(0.0) as u32; let cy = y.round().max(0.0) as u32; let pts = &mut self.documents[self.active_doc].lasso_points; // Throttle duplicate points to keep the polygon light. if pts.last().map_or(true, |l| l.0 != cx || l.1 != cy) { pts.push((cx, cy)); } } Message::LassoDragEnd => { let pts = std::mem::take(&mut self.documents[self.active_doc].lasso_points); if pts.len() >= 3 { let previous = self.documents[self.active_doc] .engine .get_selection_mask() .map(ToOwned::to_owned); let pts_ref: Vec<(u32, u32)> = pts; self.documents[self.active_doc] .engine .create_selection_lasso(&pts_ref); self.combine_current_selection(previous); self.documents[self.active_doc].selection_bounds = { let w = self.documents[self.active_doc].engine.canvas_width(); let h = self.documents[self.active_doc].engine.canvas_height(); Some((0, 0, w, h)) }; self.refresh_composite_if_needed(); } } Message::PolygonAddPoint(x, y) => { let cx = x.round().max(0.0) as u32; let cy = y.round().max(0.0) as u32; self.documents[self.active_doc] .polygon_points .push((cx, cy)); } Message::PolygonClose => { let pts = std::mem::take(&mut self.documents[self.active_doc].polygon_points); if pts.len() >= 3 { let previous = self.documents[self.active_doc] .engine .get_selection_mask() .map(ToOwned::to_owned); let pts_ref: Vec<(u32, u32)> = pts; self.documents[self.active_doc] .engine .create_selection_polygon(&pts_ref); self.combine_current_selection(previous); self.documents[self.active_doc].selection_bounds = { let w = self.documents[self.active_doc].engine.canvas_width(); let h = self.documents[self.active_doc].engine.canvas_height(); Some((0, 0, w, h)) }; self.refresh_composite_if_needed(); } } // ── Gradient tool ──────────────────────────── Message::GradientDragStart(x, y) => { self.documents[self.active_doc].gradient_drag = Some(((x, y), (x, y))); self.tool_state.is_drawing = true; } Message::GradientDragMove(x, y) => { if let Some((start, _)) = self.documents[self.active_doc].gradient_drag { self.documents[self.active_doc].gradient_drag = Some((start, (x, y))); } // Also drive the VisionSelect bbox preview. if self.tool_state.active_tool == Tool::VisionSelect { if let Some((x0, y0, _, _)) = self.documents[self.active_doc].vision_rect { self.documents[self.active_doc].vision_rect = Some((x0, y0, x, y)); } } } Message::GradientDragEnd => { if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].gradient_drag.take() { let doc = &mut self.documents[self.active_doc]; let width = doc.engine.canvas_width(); let height = doc.engine.canvas_height(); let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned); if let Some(mut pixels) = doc.engine.get_active_layer_pixels() { crate::raster::apply_gradient( &mut pixels, width, height, (x0, y0), (x1, y1), self.settings.tool_settings.gradient_type == 1, self.fg_color, self.bg_color, self.settings.tool_settings.gradient_opacity, mask.as_deref(), ); doc.engine.set_active_layer_pixels(pixels); doc.modified = true; } self.refresh_composite_if_needed(); } } // ── Crop tool ──────────────────────────────── Message::CropDragStart(x, y) => { let width = self.documents[self.active_doc].engine.canvas_width(); let height = self.documents[self.active_doc].engine.canvas_height(); self.documents[self.active_doc] .crop_state .start_drag_clamped(x, y, width, height); } Message::CropDragMove(x, y) => { self.documents[self.active_doc].crop_state.update_drag(x, y); } Message::CropDragEnd => { self.documents[self.active_doc].crop_state.end_drag(); } Message::CropConfirm => { if let Some((x, y, w, h)) = self.documents[self.active_doc].crop_state.confirm() { self.documents[self.active_doc].engine.crop(x, y, w, h); self.documents[self.active_doc].crop_state.cancel(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::CropCancel => { self.documents[self.active_doc].crop_state.cancel(); } // ── Script Editor ────────────────────────────── Message::ScriptEditorAction(action) => { self.script_content.perform(action); self.script_text = self.script_content.text(); } Message::ScriptTextChanged(text) => { self.script_text = text; } Message::ScriptRun => { match crate::script::parser::parse_dsl_script(&self.script_text) { Ok(actions) => { self.script_error = None; self.script_error_line = None; self.script_is_running = true; crate::script::executor::execute_actions( &mut self.documents[self.active_doc].engine, &actions, ); self.script_is_running = false; self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Err(e) => { self.script_error_line = crate::script::parser::parse_error_line(&e); self.script_error = Some(e); } } } Message::ScriptClear => { self.script_text.clear(); self.script_error = None; self.script_error_line = None; } // ── AI Chat ─────────────────────────────────── Message::AiChatInput(text) => { self.ai_chat_state.input = text; } Message::AiChatSend => { let user_msg = self.ai_chat_state.input.trim().to_string(); if user_msg.is_empty() || self.ai_chat_state.is_streaming { return Task::none(); } // Add user message to history self.ai_chat_state.messages.push(ChatMessage { role: "user".to_string(), content: user_msg.clone(), is_reasoning: false, }); self.ai_chat_state.input.clear(); self.ai_chat_state.is_streaming = true; self.ai_chat_state.current_response.clear(); self.ai_chat_state.current_reasoning.clear(); // Build the final prompt with optional canvas context let mut final_prompt = user_msg.clone(); if self.ai_chat_state.config.canvas_context { let doc = &self.documents[self.active_doc]; let w = doc.engine.canvas_width(); let h = doc.engine.canvas_height(); let layers = doc.engine.layer_infos(); let mut meta = format!("\n\n[Active Canvas State: Size {}x{} px. Layers:", w, h); for l in &layers { meta.push_str(&format!( "\n- \"{}\" (Type: {:?}, ID: {}, Visible: {}, Opacity: {:.0}%)", l.name, l.layer_type, l.id, l.visible, l.opacity * 100.0 )); } meta.push_str("]"); final_prompt.push_str(&meta); } // Build messages JSON for API let messages_json = ai_chat::build_messages_json( &self.ai_chat_state.messages, &final_prompt, SYSTEM_PROMPT, ); // Clone config for the async task let url = self.ai_chat_state.config.base_url.clone(); let model = self.ai_chat_state.config.model.clone(); let provider = self.ai_chat_state.config.provider.clone(); let task = Task::run( ai_chat::stream_llm_response(provider, url, model, messages_json), |update| match update { ai_chat::ChatUpdate::Chunk(chunk) => Message::AiChatStreamChunk(chunk), ai_chat::ChatUpdate::ReasoningChunk(chunk) => { Message::AiChatReasoningChunk(chunk) } ai_chat::ChatUpdate::Finished(result) => { Message::AiChatStreamFinished(result) } }, ); let (task, handle) = task.abortable(); self.ai_chat_abort = Some(handle); return task; } Message::AiChatClear => { self.ai_chat_state.messages.clear(); self.ai_chat_state.current_response.clear(); self.ai_chat_state.current_reasoning.clear(); self.ai_chat_state.is_streaming = false; if let Some(handle) = self.ai_chat_abort.take() { handle.abort(); } } Message::AiChatProviderChanged(provider) => match provider.as_str() { "Ollama" => { self.ai_chat_state.config.provider = ai_chat::AiProvider::Ollama; self.ai_chat_state.config.base_url = "http://localhost:11434".to_string(); } "Claude" => { self.ai_chat_state.config.provider = ai_chat::AiProvider::Claude; self.ai_chat_state.config.base_url = "https://api.anthropic.com".to_string(); } "OpenAI" => { self.ai_chat_state.config.provider = ai_chat::AiProvider::OpenAI; self.ai_chat_state.config.base_url = "https://api.openai.com/v1".to_string(); } _ => {} }, Message::AiChatUrlChanged(url) => { self.ai_chat_state.config.base_url = url; } Message::AiChatModelChanged(model) => { self.ai_chat_state.config.model = model; } Message::AiChatReasoningToggled(enabled) => { self.ai_chat_state.config.reasoning_enabled = enabled; } Message::AiChatCanvasContextToggled(enabled) => { self.ai_chat_state.config.canvas_context = enabled; } Message::AiChatStreamChunk(chunk) => { self.ai_chat_state.current_response.push_str(&chunk); } Message::AiChatReasoningChunk(chunk) => { self.ai_chat_state.current_reasoning.push_str(&chunk); } Message::AiChatStreamFinished(result) => { self.ai_chat_abort = None; self.ai_chat_state.is_streaming = false; match result { Ok(full_response) => { // Execute any embedded tool calls let tool_results = ai_chat::execute_inline_tools( &full_response, &mut self.documents[self.active_doc].engine, ); for res in &tool_results { self.ai_chat_state.messages.push(ChatMessage { role: "tool".to_string(), content: res.clone(), is_reasoning: false, }); } // Execute any DSL code blocks if let Some(dsl_code) = ai_chat::extract_dsl_block(&full_response) { let commands = ai_chat::parse_dsl_script( &dsl_code, &self.documents[self.active_doc].engine, ); for cmd in &commands { ai_chat::execute_command_on_engine( &mut self.documents[self.active_doc].engine, cmd, ); } self.ai_chat_state.messages.push(ChatMessage { role: "tool".to_string(), content: "▶ Automatically executed AI procedural drawing script." .to_string(), is_reasoning: false, }); self.refresh_composite_if_needed(); } // Add the full assistant response to history self.ai_chat_state.messages.push(ChatMessage { role: "assistant".to_string(), content: full_response, is_reasoning: false, }); if !self.ai_chat_state.current_reasoning.is_empty() { self.ai_chat_state.messages.push(ChatMessage { role: "assistant".to_string(), content: std::mem::take(&mut self.ai_chat_state.current_reasoning), is_reasoning: true, }); } self.ai_chat_state.current_response.clear(); } Err(e) => { self.ai_chat_state.messages.push(ChatMessage { role: "assistant".to_string(), content: format!("❌ Connection Error: {}", e), is_reasoning: false, }); } } } Message::AiChatCancel => { if let Some(handle) = self.ai_chat_abort.take() { handle.abort(); } if !self.ai_chat_state.current_response.is_empty() { self.ai_chat_state.messages.push(ChatMessage { role: "assistant".to_string(), content: std::mem::take(&mut self.ai_chat_state.current_response), is_reasoning: false, }); } self.ai_chat_state.is_streaming = false; } Message::AiChatCopyTranscript => { let mut messages = self.ai_chat_state.messages.clone(); if !self.ai_chat_state.current_response.is_empty() { messages.push(ChatMessage { role: "assistant".to_string(), content: self.ai_chat_state.current_response.clone(), is_reasoning: false, }); } if !self.ai_chat_state.current_reasoning.is_empty() { messages.push(ChatMessage { role: "assistant".to_string(), content: self.ai_chat_state.current_reasoning.clone(), is_reasoning: true, }); } return iced::clipboard::write(ai_chat::transcript_text(&messages)); } // ── AI Script Generator ─────────────────────── Message::AiScriptPromptChanged(text) => { self.ai_script_prompt = text; } Message::AiScriptProviderChanged(provider) => { self.ai_script_provider = provider.clone(); // Set sensible defaults when switching providers match provider.as_str() { "ollama" => { self.ai_script_url = "http://localhost:11434".to_string(); self.ai_script_model = "qwen2.5:7b".to_string(); } "claude" => { self.ai_script_url = "https://api.anthropic.com".to_string(); self.ai_script_model = "claude-sonnet-4-20250514".to_string(); } "openai" => { self.ai_script_url = "https://api.openai.com/v1".to_string(); self.ai_script_model = "gpt-4o".to_string(); } _ => {} } } Message::AiScriptModelChanged(model) => { self.ai_script_model = model; } Message::AiScriptUrlChanged(url) => { self.ai_script_url = url; } Message::AiScriptGenerate => { if self.ai_script_prompt.trim().is_empty() || self.ai_script_is_running { return Task::none(); } self.ai_script_is_running = true; self.ai_script_error = None; self.ai_script_notice = None; self.ai_script_output.clear(); let prompt = self.ai_script_prompt.clone(); let provider_type = self.ai_script_provider.clone(); let provider = crate::ai_script::Provider { base_url: self.ai_script_url.clone(), model: self.ai_script_model.clone(), }; return Task::perform( async move { crate::ai_script::generate_script(&provider_type, &provider, &prompt).await }, Message::AiScriptResult, ); } Message::AiScriptRun => { if self.ai_script_output.is_empty() { return Task::none(); } match crate::script::parser::parse_dsl_script(&self.ai_script_output) { Ok(actions) => { self.ai_script_error = None; self.ai_script_notice = Some(format!( "Executed {} validated script actions.", actions.len() )); crate::script::executor::execute_actions( &mut self.documents[self.active_doc].engine, &actions, ); self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Err(e) => { self.ai_script_error = Some(e); } } } Message::AiScriptCopy => { if !self.ai_script_output.is_empty() { // Copy script to the script editor panel self.script_text = self.ai_script_output.clone(); self.script_content = iced::widget::text_editor::Content::with_text(&self.ai_script_output); self.ai_script_notice = Some("Copied generated script to the Script panel.".to_string()); } } Message::AiScriptCopyOutput => { if !self.ai_script_output.is_empty() { self.ai_script_notice = Some("Copied generated script to the system clipboard.".to_string()); return iced::clipboard::write(self.ai_script_output.clone()); } } Message::AiScriptClear => { self.ai_script_prompt.clear(); self.ai_script_output.clear(); self.ai_script_error = None; self.ai_script_notice = None; self.ai_script_is_running = false; } Message::AiScriptResult(result) => { self.ai_script_is_running = false; match result { Ok(script) => { self.ai_script_output = script; self.ai_script_notice = Some("Generated script validated and is ready to run.".to_string()); } Err(e) => { self.ai_script_error = Some(e); } } } // ── Clipboard ───────────────────────────────── Message::CopyImage => { let (pixels, w, h) = { let doc = &self.documents[self.active_doc]; let canvas_w = doc.engine.canvas_width(); let canvas_h = doc.engine.canvas_height(); if let Some(mask) = doc.engine.get_selection_mask() { if let Some(transform) = selection::clipboard::extract_masked_pixels( &doc.composite_raw, mask, canvas_w, canvas_h, ) { let result = (transform.pixels.clone(), transform.width, transform.height); self.documents[self.active_doc].internal_clipboard = Some(transform); result } else { (doc.composite_raw.clone(), canvas_w, canvas_h) } } else { (doc.composite_raw.clone(), canvas_w, canvas_h) } }; return Task::perform( async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) }, Message::ImageCopied, ); } Message::CutImage => { let doc = &mut self.documents[self.active_doc]; let w = doc.engine.canvas_width(); let h = doc.engine.canvas_height(); let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned); if let Some(mask) = mask { if let Some(transform) = selection::clipboard::extract_masked_pixels(&doc.composite_raw, &mask, w, h) { let _ = crate::io::clipboard::copy_image_to_clipboard( &transform.pixels, transform.width, transform.height, ); doc.internal_clipboard = Some(transform); // Record an undoable snapshot of the source pixels before // clearing them, then clear the selected region via the engine. doc.engine.clear_selection_pixels(); doc.modified = true; } } self.refresh_composite_if_needed(); } Message::PasteImage => { // Check internal clipboard first if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone() { // Enter transform mode with the clipboard content let mut placed = transform; placed.pos.x += 10.0; placed.pos.y += 10.0; let doc = &mut self.documents[self.active_doc]; doc.transform_layer_baseline = doc.engine.get_active_layer_pixels(); doc.transform_selection_baseline = doc.engine.get_selection_mask().map(ToOwned::to_owned); doc.transform_source = None; doc.transform_preview_base = Some(doc.composite_raw.clone()); doc.selection_transform = Some(placed); doc.transform_drag_handle = TransformHandle::None; render_transform_preview(doc, None); return Task::perform(async {}, |_| Message::CompositeRefresh); } // Fall back to system clipboard 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))) => { let doc = &mut self.documents[self.active_doc]; let draft = doc.text_draft.get_or_insert_with(TextDraft::default); draft.content.push_str(&text); draft.editor = iced::widget::text_editor::Content::with_text(&draft.content); } 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 doc = &mut self.documents[self.active_doc]; let canvas_w = doc.engine.canvas_width(); let canvas_h = doc.engine.canvas_height(); doc.transform_layer_baseline = doc.engine.get_active_layer_pixels(); doc.transform_selection_baseline = doc.engine.get_selection_mask().map(ToOwned::to_owned); doc.transform_source = None; doc.transform_preview_base = Some(doc.composite_raw.clone()); doc.selection_transform = selection::clipboard::centered_transform(pixels, w, h, canvas_w, canvas_h); render_transform_preview(doc, None); } 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 => { self.pending_file_operation = Some(PendingFileOperation::Open); 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 => { if !matches!( self.pending_file_operation, Some(PendingFileOperation::SaveAs { close_after: true }) ) { self.pending_file_operation = Some(PendingFileOperation::SaveAs { close_after: false }); } 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(); match self.pending_file_operation.take() { Some(PendingFileOperation::Open) => { 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.show_welcome = false; 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.documents[self.active_doc].modified = false; 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), } } Some(PendingFileOperation::SaveAs { close_after }) => { match self.save_document_to(self.active_doc, &path) { Ok(()) => { log::info!("Saved to {}", path_str); self.finish_successful_save(self.active_doc, path); if close_after { return self.continue_close_after_save(); } } Err(error) => log::error!("Failed to save: {error}"), } } None => log::warn!("Ignoring file dialog result without a pending operation"), } } Message::FileDialogClosed(None) => { self.pending_file_operation = None; self.pending_document_close = None; } // ── Dock Layout ─────────────────────────────── Message::PaneClicked(pane) => { self.dock.active_auto_hide = None; self.dock.focus(pane); } Message::DockHeaderDragStart(pane) => { if let Some(source_type) = self .dock .pane_type(pane) .filter(|panel| *panel != PaneType::Canvas) { let origin = self .last_cursor_pos .map(|(x, y)| iced::Point::new(x, y)) .unwrap_or_default(); self.dock.header_drag = Some( crate::dock::manager::DockHeaderDragState { source_pane: pane, source_type, origin, }, ); self.dock.focus(pane); } } Message::PaneDragged(drag_event) => { use iced::widget::pane_grid::DragEvent; match drag_event { DragEvent::Picked { pane } => { if let Some(source_type) = self .dock .pane_type(pane) .filter(|panel| *panel != PaneType::Canvas) { self.dock.drag = Some(DockDragState { source_pane: pane, source_type, pointer: self.last_cursor_pos.map(|(x, y)| iced::Point::new(x, y)), candidate_target: None, accepted_preview: None, }); } } DragEvent::Dropped { pane, target } => { let approved = self.dock.drag.as_ref().is_some_and(|drag| { drag.source_pane == pane && drag.candidate_target.is_some_and(|candidate| { crate::dock::preview::target_matches(candidate, target) && crate::dock::manager::DockDropPolicy::accepts( drag.source_type, crate::dock::manager::DockDropZone::from_target(target), ) }) }); self.dock.drag = None; if approved && self.dock.apply_drop(pane, target) { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } } DragEvent::Canceled { .. } => self.dock.drag = None, } } Message::PaneResized(resize_event) => { let grid_size = self.dock.last_grid_size.unwrap_or_else(|| { crate::dock::preview::DockGeometry { viewport: iced::Size::new( self.settings.window.width, self.settings.window.height, ), toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 }, } .grid_bounds() .size() }); let ratio = crate::dock::sizing::constrain_user_resize_ratio( &self.dock.pane_grid, resize_event.split, resize_event.ratio, grid_size, ); self.dock.pane_grid.resize(resize_event.split, ratio); self.dock.resize_persistence_dirty = true; } Message::PaneClose(pane) => { // Don't close canvas panes if let Some(PaneType::Canvas) = self.dock.pane_type(pane) { return Task::none(); } if self.dock.close_pane(pane) { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } } Message::PaneMaximize(pane) => { self.dock.toggle_maximize(pane); } Message::PaneAutoHide(pane) => { if self .dock .auto_hide_pane(pane, Some(crate::dock::manager::DockEdge::Right)) { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } } Message::PaneFloat(pane) => { let viewport = (self.settings.window.width, self.settings.window.height); if self.dock.float_pane(pane, viewport) { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } } Message::FloatingFocus(panel) => { self.dock.focus_floating(panel); } Message::FloatingDragStart(panel) => { if let Some(rect) = self.dock.focus_floating(panel) { let pointer = self.last_cursor_pos.unwrap_or((rect.x, rect.y)); self.dock.floating_drag = Some(crate::dock::floating::FloatingDragState { panel, pointer_offset: iced::Point::new(pointer.0 - rect.x, pointer.1 - rect.y), }); self.dock.floating_target = None; } } Message::FloatingRedock(panel) => { if self.dock.redock_floating(panel) { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } } Message::FloatingClose(panel) => { if self.dock.close_floating(panel) { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } } Message::FloatingAutoHide(panel) => { if self .dock .auto_hide_floating(panel, crate::dock::manager::DockEdge::Right) { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } } Message::DockViewportChanged(size) => { self.settings.window.width = size.width; self.settings.window.height = size.height; self.dock.clamp_floating((size.width, size.height)); let grid_size = crate::dock::preview::DockGeometry { viewport: size, toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 }, } .grid_bounds() .size(); self.dock.set_grid_size(grid_size); } Message::AutoHideActivate(panel) => { self.dock.active_auto_hide = if self.dock.active_auto_hide == Some(panel) { None } else { Some(panel) }; } Message::AutoHideDismiss => self.dock.active_auto_hide = None, Message::AutoHideRestore(panel) => { if self.dock.restore_auto_hidden(panel) { self.settings .panel_layout .persist_current_placement(&self.dock); let _ = self.settings.save(); } } // ── Dock Profiles ───────────────────────────── Message::DockProfileSave(name) => { let name = name.trim().to_string(); if name.is_empty() { return Task::none(); } // Collect the list of panel types currently open in the dock let visible_panels: Vec = self .dock .pane_grid .iter() .map(|(_, pane_type)| pane_type.label().to_string()) .collect(); let profile = crate::settings::DockProfile { name: name.clone(), visible_panels, left_col1_w: 0.0, left_col2_w: 0.0, right_col1_w: 0.0, right_col2_w: 0.0, last_window_width: 0.0, }; self.settings.dock_profiles.retain(|p| p.name != name); self.settings.dock_profiles.push(profile); self.active_dock_profile = Some(name.clone()); self.dock_profile_new_name.clear(); log::info!("Dock profile saved: {}", name); let _ = self.settings.save(); self.active_dialog = ActiveDialog::None; } Message::DockProfileLoad(name) => { if let Some(profile) = self.settings.dock_profiles.iter().find(|p| p.name == *name) { // Reopen all panels listed in the saved profile let panel_names = profile.visible_panels.clone(); self.dock = crate::dock::state::DockState::new(); for panel_name in &panel_names { if let Some(pane_type) = pane_type_from_label(panel_name) { self.dock.reopen_pane(pane_type); } } self.active_dock_profile = Some(name.clone()); log::info!("Dock profile applied: {}", name); } } Message::DockProfileRename(old_name, new_name) => { let new_name = new_name.trim().to_string(); if new_name.is_empty() { return Task::none(); } if let Some(profile) = self .settings .dock_profiles .iter_mut() .find(|p| p.name == old_name) { profile.name = new_name.clone(); if self.active_dock_profile.as_deref() == Some(&old_name) { self.active_dock_profile = Some(new_name); } log::info!("Dock profile renamed: {} -> {}", old_name, profile.name); let _ = self.settings.save(); } self.dock_profile_rename_idx = None; self.dock_profile_rename_buf.clear(); } Message::DockProfileDelete(name) => { let before = self.settings.dock_profiles.len(); self.settings.dock_profiles.retain(|p| p.name != *name); if self.settings.dock_profiles.len() < before { if self.active_dock_profile.as_deref() == Some(&name) { self.active_dock_profile = None; } log::info!("Dock profile deleted: {}", name); let _ = self.settings.save(); } } Message::DockProfileRenameStart(name) => { if let Some(idx) = self .settings .dock_profiles .iter() .position(|p| p.name == name) { self.dock_profile_rename_idx = Some(idx); self.dock_profile_rename_buf = name; } } Message::DockProfileRenameConfirm => { if let Some(idx) = self.dock_profile_rename_idx { if idx < self.settings.dock_profiles.len() { let old_name = self.settings.dock_profiles[idx].name.clone(); let new_name = self.dock_profile_rename_buf.trim().to_string(); if !new_name.is_empty() { self.settings.dock_profiles[idx].name = new_name.clone(); if self.active_dock_profile.as_deref() == Some(&old_name) { self.active_dock_profile = Some(new_name); } log::info!( "Dock profile renamed: {} -> {}", old_name, self.settings.dock_profiles[idx].name ); let _ = self.settings.save(); } } } self.dock_profile_rename_idx = None; self.dock_profile_rename_buf.clear(); } Message::DockProfileRenameCancel => { self.dock_profile_rename_idx = None; self.dock_profile_rename_buf.clear(); } Message::DockProfileNewNameChanged(name) => { self.dock_profile_new_name = name; } Message::DockProfileRenameChanged(name) => { self.dock_profile_rename_buf = name; } Message::DockProfileDialogShow => { self.show_dock_profile_dialog = true; } Message::DockProfileDialogHide => { self.show_dock_profile_dialog = false; self.dock_profile_rename_idx = None; self.dock_profile_rename_buf.clear(); } // ── File I/O ───────────────────────────────── Message::NewDocument(w, h) => { self.show_welcome = false; 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), selection_transform: None, transform_drag_handle: TransformHandle::None, transform_drag_start: None, transform_original: None, transform_source: None, transform_layer_baseline: None, transform_selection_baseline: None, transform_preview_base: None, internal_clipboard: None, selection_mask: None, selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, selection_history: Vec::new(), selection_history_marker: 0, crop_state: CropState::default(), lasso_points: Vec::new(), polygon_points: Vec::new(), gradient_drag: None, vision_rect: None, text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, selection_fill_spans: None, selection_mode: selection::SelectionMode::Replace, saved_selection_mask: None, quick_mask: false, selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, }); } Message::DocSwitch(idx) => { if idx < self.documents.len() { if self.preview_baseline.is_some() { self.restore_preview(); self.filter_preview_active = false; self.selected_filter = None; self.filter_params = serde_json::json!({}); } self.active_doc = idx; } } Message::DocClose(idx) => { let modified = self .documents .get(idx) .is_some_and(|document| document.modified); match document_close_disposition(self.documents.len(), idx, modified) { DocumentCloseDisposition::Invalid => return Task::none(), DocumentCloseDisposition::Welcome | DocumentCloseDisposition::Confirm | DocumentCloseDisposition::Close => { if self.preview_baseline.is_some() { self.restore_preview(); self.filter_preview_active = false; } } } if modified { self.active_doc = idx; self.pending_document_close = Some(idx); self.active_dialog = ActiveDialog::CloseConfirm; } else { self.close_document_tab(idx); } } Message::OpenFile => { return self.update(Message::OpenFileRfd); } Message::FileOpened(Ok(path)) => { log::info!("File opened: {:?}", path); } Message::FileOpened(Err(e)) => { log::error!("Failed to open file: {}", e); } Message::SaveFile => { if let Some(path) = self.documents[self.active_doc].source_path.clone() { match self.save_document_to(self.active_doc, &path) { Ok(()) => self.finish_successful_save(self.active_doc, path), Err(error) => log::error!("Failed to save: {error}"), } } else { return self.update(Message::SaveFileAs); } } // ── Menu ───────────────────────────────────── Message::MenuOpen(idx) => { self.sub_tools_open = false; if self.active_menu == Some(idx) { self.active_menu = None; } else { self.active_menu = Some(idx); } } Message::MenuClose => { // If crop tool is active with an active crop, cancel it first if self.tool_state.active_tool == Tool::Crop && self.documents[self.active_doc].crop_state.active { self.documents[self.active_doc].crop_state.cancel(); } else if self.documents[self.active_doc] .selection_transform .is_some() { return self.update(Message::TransformCancel); } else if self.active_menu.is_some() { self.active_menu = None; } else if self.active_dialog != ActiveDialog::None { self.active_dialog = ActiveDialog::None; } } Message::MenuCommand(crate::panels::menus::MenuCommand::OpenRecent(path_str)) => { self.active_menu = None; 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 { log::error!("Recent file no longer exists: {}", path_str); } } Message::MenuCommand(crate::panels::menus::MenuCommand::ClearRecent) => { self.active_menu = None; self.recent_files.clear(); save_recent_files(&self.recent_files); } Message::MenuCommand(command) => { use crate::panels::menus::MenuCommand; self.active_menu = None; match command { MenuCommand::NewDocument => { return Task::perform(async {}, |_| { Message::DialogOpen(ActiveDialog::NewImage) }) } MenuCommand::OpenFile => { return Task::perform(async {}, |_| Message::OpenFileRfd) } MenuCommand::Save => return Task::perform(async {}, |_| Message::SaveFile), MenuCommand::SaveAs => return Task::perform(async {}, |_| Message::SaveFileAs), MenuCommand::Undo => return Task::perform(async {}, |_| Message::Undo), MenuCommand::Redo => return Task::perform(async {}, |_| Message::Redo), MenuCommand::Copy => return Task::perform(async {}, |_| Message::CopyImage), MenuCommand::Cut => return Task::perform(async {}, |_| Message::CutImage), MenuCommand::ClearPixels => { return Task::perform(async {}, |_| Message::ClearPixels) } MenuCommand::Paste => return Task::perform(async {}, |_| Message::PasteImage), MenuCommand::CanvasSize => { let (w, h) = self.documents[self.active_doc].engine.get_canvas_size(); self.dialog_canvas_size_width = w; self.dialog_canvas_size_height = h; self.dialog_canvas_size_anchor = (1, 1); self.active_dialog = ActiveDialog::CanvasSize; } MenuCommand::ImageSize => { let (w, h) = self.documents[self.active_doc].engine.get_canvas_size(); self.dialog_image_size_width = w; self.dialog_image_size_height = h; self.dialog_image_size_original_ratio = w as f32 / h as f32; self.dialog_image_size_constrain = true; self.active_dialog = ActiveDialog::ImageSize; } MenuCommand::AddLayer => return Task::perform(async {}, |_| Message::LayerAdd), MenuCommand::DeleteLayer => { let id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.delete_layer(id); } MenuCommand::LayerStyles => { return Task::perform(async {}, |_| Message::OpenLayerStyleDialog) } MenuCommand::MergeDown => { return Task::perform(async {}, |_| Message::LayerMergeDown) } MenuCommand::Flatten => { return Task::perform(async {}, |_| Message::LayerFlatten) } MenuCommand::SelectAll => { let w = self.documents[self.active_doc].engine.canvas_width() as f32; let h = self.documents[self.active_doc].engine.canvas_height() as f32; self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h)); } MenuCommand::Deselect => { self.documents[self.active_doc].selection_rect = None; } MenuCommand::InvertSelection => { self.documents[self.active_doc].engine.selection_invert(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } MenuCommand::GrowSelection => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } MenuCommand::ShrinkSelection => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } MenuCommand::FeatherSelection => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } MenuCommand::BorderSelection => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Border"); } MenuCommand::SmoothSelection => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Smooth"); } MenuCommand::SelectionMode(mode) => { return Task::perform(async move {}, move |_| { Message::SelectionModeChanged(mode) }) } MenuCommand::SaveSelection => { return Task::perform(async {}, |_| Message::SelectionSave) } MenuCommand::LoadSelection => { return Task::perform(async {}, |_| Message::SelectionLoad) } MenuCommand::ToggleQuickMask => { return Task::perform(async {}, |_| Message::QuickMaskToggle) } MenuCommand::ZoomIn => { return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)) } MenuCommand::ZoomOut => { return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)) } MenuCommand::FitArea => { let doc = &mut self.documents[self.active_doc]; let fit_x = (doc.pane_size.0 - 24.0) / doc.engine.canvas_width() as f32; let fit_y = (doc.pane_size.1 - 24.0) / doc.engine.canvas_height() as f32; doc.zoom = fit_x.min(fit_y).clamp(ZOOM_MIN, ZOOM_MAX); doc.pan_offset = Vector::ZERO; } MenuCommand::ActualPixels => { return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)) } MenuCommand::SelectCropTool => { return Task::perform(async {}, |_| Message::ToolSelected(Tool::Crop)) } MenuCommand::GaussianBlur => { return Task::perform(async {}, |_| { Message::FilterSelect(hcie_engine_api::FilterType::GaussianBlur) }) } MenuCommand::Mosaic => { return Task::perform(async {}, |_| { Message::FilterSelect(hcie_engine_api::FilterType::Mosaic) }) } MenuCommand::UnsharpMask => { return Task::perform(async {}, |_| { Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask) }) } MenuCommand::TogglePane(pane) => self.dock.toggle_pane(pane), MenuCommand::SetTheme(theme) => { self.theme_state.set_preset(theme); self.settings.theme_preset = theme; let _ = self.settings.save(); } MenuCommand::OpenRecent(_) | MenuCommand::ClearRecent => { unreachable!("handled before general menu dispatch") } } } // ── 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 => { self.pending_document_close = None; if let Some(document_index) = self.documents.iter().position(|document| document.modified) { self.active_doc = document_index; self.active_dialog = ActiveDialog::CloseConfirm; } else if let Some(id) = self.window_id { return iced::window::close(id); } } Message::WindowId(id) => { self.window_id = Some(id); if self.screenshot_request.is_some() { return Task::perform( async { std::thread::sleep(std::time::Duration::from_millis(750)); }, |_| Message::ScreenshotCapture, ); } } Message::ScreenshotCapture => { if self.screenshot_request.is_none() { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |duration| duration.as_secs()); self.screenshot_request = Some(crate::cli::ScreenshotRequest { panel: None, floating_panel: None, auto_hide_panel: None, welcome: false, output: std::path::PathBuf::from(format!( "target/screenshots/iced_{timestamp}.png" )), }); } if let Some(id) = self.window_id { return iced::window::screenshot(id).map(Message::ScreenshotReady); } log::error!("cannot capture screenshot before the window is initialized"); } Message::ScreenshotReady(screenshot) => { if let Some(request) = self.screenshot_request.take() { let captured = if let Some(panel) = request.panel.as_deref() { crate::screenshot::crop_panel( &screenshot, &self.dock, panel, self.sidebar_expanded, ) } else { Ok(screenshot) }; if let Err(error) = captured.and_then(|captured| { crate::screenshot::save_png(&captured, &request.output) }) { log::error!("{error}"); } else { log::info!("screenshot saved to {}", request.output.display()); } if let Some(id) = self.window_id { return iced::window::close(id); } } } Message::ThemeChanged(preset) => { self.theme_state.set_preset(preset); self.settings.theme_preset = preset; let _ = self.settings.save(); } // ── Image Viewer ───────────────────────────────── Message::ViewerToggle => { self.viewer_active = !self.viewer_active; if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() { // Initialize to home directory on first open let start_dir = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); self.viewer_state = crate::viewer::ViewerState::new(start_dir); } if self.viewer_active { self.viewer_state.load_preview(); } } Message::ViewerSetMode(mode) => { self.viewer_state.view_mode = mode; self.viewer_state.load_preview(); } Message::ViewerNavigate(path) => { self.viewer_state.set_dir(path); self.viewer_state.load_preview(); } Message::ViewerSelectFile(path) => { // Select the file in the grid if let Some(idx) = self.viewer_state.images.iter().position(|p| p == &path) { self.viewer_state.active_image_idx = idx; self.viewer_state.load_preview(); self.viewer_state.view_mode = crate::viewer::ViewerMode::Viewer; } } Message::ViewerOpenFile(path) => { // Open into a new editor document so browsing never overwrites a dirty canvas. let _ = self.update(Message::NewDocument(1, 1)); let document_index = self.documents.len() - 1; 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" => self.documents[document_index].engine.import_psd(&path_str), "kra" => self.documents[document_index].engine.import_kra(&path_str), "hcie" => self.documents[document_index].engine.load_native(&path_str), _ => self.documents[document_index].engine.open_image(&path_str), }; match result { Ok(()) => { self.viewer_active = false; self.active_doc = document_index; self.documents[document_index].engine.pre_tile_all_layers(); self.documents[document_index].name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled".to_string()); self.documents[document_index].source_path = Some(path.clone()); self.add_recent_file(&path); let composite = self.documents[document_index].engine.get_composite_pixels(); self.documents[document_index].composite_raw = composite; self.documents[document_index].composite_pixels = std::sync::Arc::new( self.documents[document_index].composite_raw.clone(), ); self.documents[document_index].full_upload.replace(true); self.documents[document_index].render_generation = self.documents [document_index] .render_generation .wrapping_add(1); self.documents[document_index].cached_layers = self.documents[document_index].engine.layer_infos(); let history_len = self.documents[document_index].engine.history_len(); self.documents[document_index].cached_history = (0..history_len) .filter_map(|i| { self.documents[document_index] .engine .history_description(i) .map(|d| (i, d)) }) .collect(); self.documents[document_index].history_current = self.documents[document_index].engine.history_current(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Err(e) => { self.documents.remove(document_index); log::error!("Failed to open file from viewer: {}", e); self.viewer_state.load_error = Some(format!("Could not edit {}: {e}", path.display())); } } } Message::ViewerRefresh => { self.viewer_state.clear_caches(); self.viewer_state.load_preview(); } Message::ViewerFullscreen(fs) => { self.viewer_state.fullscreen = fs; if let Some(id) = self.window_id { let mode = if fs { iced::window::Mode::Fullscreen } else { iced::window::Mode::Windowed }; return iced::window::change_mode(id, mode); } } Message::ViewerPrev => { if self.viewer_active { self.viewer_state.prev_image(); self.viewer_state.load_preview(); } } Message::ViewerNext => { if self.viewer_active { self.viewer_state.next_image(); self.viewer_state.load_preview(); } } Message::ViewerEnter => { if self.viewer_active && !self.viewer_state.images.is_empty() { let path = self.viewer_state.images[self.viewer_state.active_image_idx].clone(); return Task::perform(async move {}, move |_| { Message::ViewerOpenFile(path.clone()) }); } } Message::SidebarToggleExpanded => { self.sidebar_expanded = !self.sidebar_expanded; self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded; self.settings.panel_layout.sidebar_width = if self.sidebar_expanded { 72.0 } else { 36.0 }; let grid_size = crate::dock::preview::DockGeometry { viewport: iced::Size::new( self.settings.window.width, self.settings.window.height, ), toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 }, } .grid_bounds() .size(); self.dock.set_grid_size(grid_size); let _ = self.settings.save(); } Message::NoOp => {} Message::TabletTouch { x, y, pressed } => { // Forward touch/pen position to the shared tablet state. // iced 0.13 touch events carry no pressure, so this only updates // position/proximity; a finger lift resets pressure to the mouse // default (1.0) so the next mouse stroke is full-pressure. if let Ok(mut ts) = self.tablet_state.lock() { if pressed { ts.set_screen_pos(x, y); } else { ts.reset_pressure(); } } if !pressed { return Task::perform(async {}, |_| Message::DockReleaseCleanup); } } // ── Contextual key actions ─────────────────────── Message::EscapePressed => { if self.dock.active_auto_hide.take().is_some() { return Task::none(); } // Mirrors egui's Escape handling. Priority: viewer > crop > // transform > vector selection > selection > dialog > viewer fallback. match overlay_escape_target(self.sub_tools_open, self.active_menu.is_some()) { Some(OverlayEscapeTarget::Subtools) => { self.sub_tools_open = false; return Task::none(); } Some(OverlayEscapeTarget::Menu) => { self.active_menu = None; return Task::none(); } None => {} } if self.viewer_active { return self.update(Message::ViewerToggle); } else if self.sub_tools_open || self.active_menu.is_some() { return Task::none(); } else if self.documents[self.active_doc].crop_state.active { return self.update(Message::CropCancel); } else if self.documents[self.active_doc] .selection_transform .is_some() { return self.update(Message::TransformCancel); } else if self.documents[self.active_doc] .selected_vector_shape .is_some() { return self.update(Message::VectorShapeCancel); } else if self.documents[self.active_doc].selection_bounds.is_some() || self.documents[self.active_doc].selection_rect.is_some() { return self.update(Message::Deselect); } else if self.active_dialog != ActiveDialog::None { return self.update(Message::DialogClose); } else { return self.update(Message::ViewerToggle); } } Message::EnterPressed => { // Mirrors egui's Enter: crop > transform > text-commit > vector-apply > viewer. if self.documents[self.active_doc].crop_state.active { return self.update(Message::CropConfirm); } else if self.documents[self.active_doc] .selection_transform .is_some() { return self.update(Message::TransformApply); } else if self.active_document_mut().text_draft.is_some() { return self.update(Message::TextCommit); } else if self.documents[self.active_doc] .selected_vector_shape .is_some() { return self.update(Message::VectorShapeApply); } else { return self.update(Message::ViewerEnter); } } Message::ModifiersChanged(modifiers) => { self.modifiers = modifiers; } Message::SpacePanChanged(held) => { self.tool_state.space_pan = held; } } // Persist colors to disk when they have changed (debounced via the // colors_dirty flag so we only write once per change-set, not per frame). if self.colors_dirty { self.colors_dirty = false; save_colors(self.fg_color, self.bg_color, &self.recent_colors); } // Lazy-load thumbnails when viewer is active (max LAZY_LOAD_BUDGET per frame) if self.viewer_active { let batch = crate::viewer::thumbnail_grid::next_load_batch(&self.viewer_state); for path in batch { self.viewer_state.load_thumbnail(&path); } } 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 colors = self.theme_state.colors(); // When the viewer is active, show the full-screen image viewer if self.viewer_active { let viewer_panel = crate::viewer::view(&self.viewer_state, colors); return container(viewer_panel) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_app)), ..Default::default() }) .into(); } let doc = &self.documents[self.active_doc]; // Title bar (now includes menu items and window controls) let title_bar = panels::title_bar::view( &doc.name, doc.modified, doc.zoom, doc.engine.canvas_width(), doc.engine.canvas_height(), colors, self.active_menu, ); // Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge log::info!("[view] sidebar_expanded={}", self.sidebar_expanded); let toolbox = crate::sidebar::view( &self.tool_state, &self.fg_color, &self.bg_color, colors, self.active_tool_slot, self.sub_tools_open, &self.slot_last_used, self.sidebar_expanded, ); // Merged toolbar: icon buttons + tool options in one row let toolbar = panels::toolbar::view(&self.tool_state, &self.settings, colors); // Dock grid (without toolbox) let dock_content = crate::dock::view::dock_view(&self.dock, self); // Main area: toolbox + dock side by side. 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, toolbar, 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, colors, ), ActiveDialog::BrightnessContrast => dialogs::adjustments::brightness_contrast_view( self.dialog_brightness, self.dialog_contrast, colors, ), ActiveDialog::HueSaturation => dialogs::adjustments::hsl_view( self.dialog_hue, self.dialog_saturation, self.dialog_lightness, colors, ), ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name, colors), ActiveDialog::SelectionOp(op) => dialogs::confirm::selection_op_view( op, self.dialog_selection_value, 1.0, 100.0, colors, ), 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, ) } ActiveDialog::ImageSize => dialogs::image_size::view( self.dialog_image_size_width, self.dialog_image_size_height, self.dialog_image_size_constrain, colors, ), ActiveDialog::CanvasSize => dialogs::canvas_size::view( self.dialog_canvas_size_width, self.dialog_canvas_size_height, self.dialog_canvas_size_anchor.0, self.dialog_canvas_size_anchor.1, colors, ), ActiveDialog::About => dialogs::about::view(colors), }; // Stack main content with overlays (dialog + menu dropdown + dock profile dialog). // `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(); let has_dock_profile_dialog = self.show_dock_profile_dialog; let has_context_menu = self.canvas_context_menu.is_some(); let has_color_picker = self.color_picker_target.is_some(); let has_floating = !self.dock.floating.is_empty(); if has_dialog || has_menu || has_dock_profile_dialog || has_context_menu || has_color_picker || has_floating { let mut stack = iced::widget::Stack::new().push(content); for item in &self.dock.floating { stack = stack.push(crate::dock::floating::card(item, self, colors)); } if has_dialog { stack = stack.push(dialog_overlay); } if has_dock_profile_dialog { stack = stack.push(dialogs::dock_profile::view( &self.settings.dock_profiles, self.active_dock_profile.as_deref(), &self.dock_profile_new_name, colors, )); } if let Some(menu_overlay) = panels::menus::dropdown_overlay( self.active_menu, &self.recent_files, self.theme_state.colors(), Some(&self.dock), self.settings.window.width, ) { stack = stack.push(menu_overlay); } if let Some((cx, cy)) = self.canvas_context_menu { stack = stack.push(panels::menus::canvas_context_menu( cx, cy, &self.documents[self.active_doc], self.theme_state.colors(), )); } if has_color_picker { stack = stack.push(crate::color_picker::popup_view( self.popup_color, &self.popup_color_hex, self.popup_color_tab, colors, )); } 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 { if self.theme_state.colors().is_light { Theme::Light } else { Theme::Dark } } /// Subscriptions — keyboard events for shortcuts. /// /// Handles all keyboard shortcuts matching the egui GUI: /// - Ctrl+Z: Undo /// - Ctrl+Y / Ctrl+Shift+Z: Redo /// - Ctrl+S: Save /// - Ctrl+Shift+S: Save As /// - Ctrl+N: New /// - Ctrl+O: Open /// - Ctrl+C: Copy /// - Ctrl+V: Paste /// - Ctrl+A: Select All /// - Ctrl+D: Deselect /// - Ctrl+Shift+I: Inverse Selection /// - Ctrl++: Zoom In /// - Ctrl+-: Zoom Out /// - Ctrl+0: Fit to Screen /// - Ctrl+1: 100% Zoom /// - Delete: Clear selection /// - F11: Fullscreen toggle /// - B: Brush, E: Eraser, V: Move, M: Marquee, L: Lasso /// - W: Magic Wand, T: Text, U: Shape (Vector Rectangle) /// - Escape: Close dialog / exit viewer /// - Enter: Confirm / open in viewer 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 { // ── Modifier combos (Ctrl+...) ─────────────── // 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 && !shift => { 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::SaveFile) } // Ctrl+Shift+S = Save As 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 && !shift => { Some(Message::DialogOpen(ActiveDialog::NewImage)) } // Ctrl+O = Open iced::keyboard::Key::Character(ref c) if c.as_str() == "o" && ctrl && !shift => { 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) } iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => { Some(Message::CutImage) } // Ctrl+A = Select All iced::keyboard::Key::Character(ref c) if c.as_str() == "a" && ctrl && !shift => { Some(Message::SelectAll) } // Ctrl+D = Deselect iced::keyboard::Key::Character(ref c) if c.as_str().eq_ignore_ascii_case("d") && ctrl && !shift => { Some(Message::Deselect) } // Ctrl+Shift+I = Inverse Selection iced::keyboard::Key::Character(ref c) if c.as_str() == "i" && ctrl && shift => { Some(Message::SelectInverse) } // Ctrl++ = Zoom In iced::keyboard::Key::Character(ref c) if (c.as_str() == "=" || c.as_str() == "+") && ctrl && !shift => { Some(Message::CanvasZoomRelative(1.1)) } // Ctrl+- = Zoom Out iced::keyboard::Key::Character(ref c) if c.as_str() == "-" && ctrl && !shift => { Some(Message::CanvasZoomRelative(1.0 / 1.1)) } // Ctrl+0 = true fit to the measured canvas pane. iced::keyboard::Key::Character(ref c) if c.as_str() == "0" && ctrl && !shift => { Some(Message::FitToArea) } // Ctrl+1 = 100% Zoom iced::keyboard::Key::Character(ref c) if c.as_str() == "1" && ctrl && !shift => { Some(Message::CanvasZoomSet(1.0)) } // ── Single-key tool shortcuts (no modifiers) ── // B = Brush Tool iced::keyboard::Key::Character(ref c) if c.as_str() == "b" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Brush)) } // E = Eraser Tool iced::keyboard::Key::Character(ref c) if c.as_str() == "e" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Eraser)) } // V = Move Tool iced::keyboard::Key::Character(ref c) if c.as_str() == "v" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Move)) } // M = Marquee (Rect Select) Tool iced::keyboard::Key::Character(ref c) if c.as_str() == "m" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Select)) } // L = Lasso Tool iced::keyboard::Key::Character(ref c) if c.as_str() == "l" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Lasso)) } // W = Magic Wand iced::keyboard::Key::Character(ref c) if c.as_str() == "w" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::MagicWand)) } // T = Text Tool iced::keyboard::Key::Character(ref c) if c.as_str() == "t" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Text)) } // U = Shape Tool (Vector Rectangle) iced::keyboard::Key::Character(ref c) if c.as_str() == "u" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::VectorRect)) } // P = Pen Tool (egui: app/mod.rs:2963) iced::keyboard::Key::Character(ref c) if c.as_str() == "p" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Pen)) } // F = Flood Fill (egui: app/mod.rs:2965) iced::keyboard::Key::Character(ref c) if c.as_str() == "f" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::FloodFill)) } // G = Gradient (egui: app/mod.rs:2966) iced::keyboard::Key::Character(ref c) if c.as_str() == "g" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Gradient)) } // C = Crop (egui: app/mod.rs:2969) iced::keyboard::Key::Character(ref c) if c.as_str() == "c" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Crop)) } // I = Eyedropper (egui: app/mod.rs:2992) iced::keyboard::Key::Character(ref c) if c.as_str() == "i" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Eyedropper)) } // J = Smart Select (egui groups SmartSelect with MagicWand slot) iced::keyboard::Key::Character(ref c) if c.as_str() == "j" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::SmartSelect)) } // X = Swap fg/bg colors (egui: app/mod.rs:2995) iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && !ctrl && !shift => { Some(Message::SwapColors) } iced::keyboard::Key::Character(ref c) if c.as_str() == "q" && !ctrl && !shift => { Some(Message::QuickMaskToggle) } // Shift+Tab toggles the expanded, scrollable toolbox. iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) if shift && !ctrl => { Some(Message::SidebarToggleExpanded) } // [ = Decrease brush size (egui: app/mod.rs:3001). The closure // must be a non-capturing fn, so we emit a relative delta and // apply it in update(). iced::keyboard::Key::Character(ref c) if c.as_str() == "[" && !ctrl && !shift => { Some(Message::BrushSizeRelative(-2.0)) } // ] = Increase brush size (egui: app/mod.rs:3003) iced::keyboard::Key::Character(ref c) if c.as_str() == "]" && !ctrl && !shift => { Some(Message::BrushSizeRelative(2.0)) } // Y = Crop confirm (handled in app-level via Enter below; Y is // reserved — egui maps Enter to crop confirm). // ── Named keys ─────────────────────────────── // Delete clears selected raster pixels; vector deletion remains available on-canvas. iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete) => { Some(Message::ClearPixels) } // F11 = Fullscreen toggle iced::keyboard::Key::Named(iced::keyboard::key::Named::F11) => { Some(Message::WindowMaximize) } // F12 captures the native Iced viewport through the same path used by CLI audits. iced::keyboard::Key::Named(iced::keyboard::key::Named::F12) => { Some(Message::ScreenshotCapture) } // Tab = Toggle panel visibility (egui: app/mod.rs:3011). We // toggle the dock profile dialog open/closed as a stand-in. iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) => { Some(Message::DockProfileDialogShow) } // Arrow keys — viewer navigation iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowLeft) => { Some(Message::ViewerPrev) } iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowRight) => { Some(Message::ViewerNext) } // Escape = contextual cancel. The closure must be a // non-capturing fn, so we emit a dedicated message and resolve // the cancel target (viewer/crop/transform/selection/dialog) in // update() where `self` is available. iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => { Some(Message::EscapePressed) } // Enter = contextual confirm (crop/transform/text-commit, else // viewer open). Resolved in update(). iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) => { Some(Message::EnterPressed) } _ => None, } }); // Mouse events for dialog dragging, plus touch/pen position feed to the // shared tablet state so evdev-less touchscreens still update position. let mouse = iced::event::listen_with(|event, status, _id| { match event { iced::Event::Mouse(mouse_event) => match mouse_event { iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => (status == iced::event::Status::Ignored) .then_some(Message::OverlayOutsideClick), iced::mouse::Event::CursorMoved { position } => { Some(Message::DockPointerMoved(position.x, position.y)) } iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => { Some(Message::DockPointerReleased) } _ => None, }, iced::Event::Window(iced::window::Event::Opened { size, .. }) | iced::Event::Window(iced::window::Event::Resized(size)) => { Some(Message::DockViewportChanged(size)) } iced::Event::Touch(touch_event) => { // iced 0.13 touch events do not carry a force/pressure field, // so we can only forward position. A touch press/move marks // the tablet as in-proximity; lifting resets pressure to the // mouse default (1.0) so subsequent mouse strokes behave. match touch_event { iced::touch::Event::FingerPressed { position, .. } | iced::touch::Event::FingerMoved { position, .. } => { Some(Message::TabletTouch { x: position.x, y: position.y, pressed: true, }) } iced::touch::Event::FingerLifted { .. } | iced::touch::Event::FingerLost { .. } => Some(Message::TabletTouch { x: 0.0, y: 0.0, pressed: false, }), } } iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) => { Some(Message::ModifiersChanged(modifiers)) } iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Space), .. }) => Some(Message::SpacePanChanged(true)), iced::Event::Keyboard(iced::keyboard::Event::KeyReleased { key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Space), .. }) => Some(Message::SpacePanChanged(false)), _ => None, } }); // The marching ants are rendered in the persistent GPU canvas shader. // Use a real timer rather than an immediately-ready async loop: the old // loop generated an unbounded stream of CompositeRefresh messages and // starved pointer events while drawing. let marching_ants = if self .documents .iter() .any(|doc| doc.selection_bounds.is_some()) && !self.tool_state.is_drawing { iced::time::every(std::time::Duration::from_millis(50)) .map(|_| Message::CompositeRefresh) } else { iced::Subscription::none() }; iced::Subscription::batch(vec![keyboard, mouse, marching_ants]) } } /// Identifies which transient overlay consumes Escape before editor-level cancellation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OverlayEscapeTarget { Subtools, Menu, } /// Resolves Escape priority for transient navigation surfaces. /// /// **Arguments:** `subtools_open` and `menu_open` describe the two transient overlays. /// **Returns:** The single overlay to close, preferring the more specific subtool popup. /// **Logic & Workflow:** Popup priority is stable even if inconsistent state briefly exposes both. /// **Side Effects / Dependencies:** None. fn overlay_escape_target(subtools_open: bool, menu_open: bool) -> Option { if subtools_open { Some(OverlayEscapeTarget::Subtools) } else if menu_open { Some(OverlayEscapeTarget::Menu) } else { None } } /// Semantic result of pressing one document tab's close control. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DocumentCloseDisposition { Invalid, Welcome, Confirm, Close, } /// Resolves tab-close behavior without mutating application state. /// /// Arguments: open document count, requested index, and modified state. Returns: whether to ignore, /// show the welcome state, confirm, or remove immediately. Side Effects: None. fn document_close_disposition( document_count: usize, document_index: usize, modified: bool, ) -> DocumentCloseDisposition { if document_index >= document_count { DocumentCloseDisposition::Invalid } else if modified { DocumentCloseDisposition::Confirm } else if document_count == 1 { DocumentCloseDisposition::Welcome } else { DocumentCloseDisposition::Close } } /// Computes a safe active index after one tab has been removed. /// /// Arguments: prior active index, removed index, and remaining document count. Returns: a valid /// remaining index. Logic & Workflow: shifts indices after the removed tab and selects the nearest /// surviving tab when the active one closes. Side Effects: None. fn active_index_after_document_close( active_index: usize, removed_index: usize, remaining_count: usize, ) -> usize { debug_assert!(remaining_count > 0); if active_index > removed_index { active_index - 1 } else if active_index == removed_index { removed_index.min(remaining_count - 1) } else { active_index.min(remaining_count - 1) } } #[cfg(test)] mod cycle_one_ux_tests { use super::{ active_index_after_document_close, document_close_disposition, overlay_escape_target, DocumentCloseDisposition, OverlayEscapeTarget, }; /// Confirms Escape dismisses subtools before menus and leaves editor cancellation untouched. #[test] fn popup_and_menu_escape_priority_is_stable() { assert_eq!( overlay_escape_target(true, true), Some(OverlayEscapeTarget::Subtools) ); assert_eq!( overlay_escape_target(false, true), Some(OverlayEscapeTarget::Menu) ); assert_eq!(overlay_escape_target(false, false), None); } /// Confirms dirty tabs require confirmation, clean tabs close directly, and the final tab uses /// the existing whole-window confirmation path. #[test] fn document_tab_close_uses_semantic_continuations() { assert_eq!( document_close_disposition(3, 1, true), DocumentCloseDisposition::Confirm ); assert_eq!( document_close_disposition(3, 1, false), DocumentCloseDisposition::Close ); assert_eq!( document_close_disposition(1, 0, false), DocumentCloseDisposition::Welcome ); assert_eq!( document_close_disposition(2, 2, false), DocumentCloseDisposition::Invalid ); } /// Confirms closing tabs before, at, and after the active tab never leaves a stale index. #[test] fn document_tab_close_keeps_active_index_safe() { assert_eq!(active_index_after_document_close(2, 0, 2), 1); assert_eq!(active_index_after_document_close(1, 1, 2), 1); assert_eq!(active_index_after_document_close(2, 2, 2), 1); assert_eq!(active_index_after_document_close(0, 2, 2), 0); } } /// Find the topmost text layer whose rasterized pixels contain the point /// `(x, y)` in canvas coordinates. fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option { 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 && info.visible { if let Some(px) = engine.get_pixel(info.id, ux, uy) { if px[3] > 10 { return Some(info.id); } } } } None }