Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/app.rs
T
Your Name a64f1184cd
mandatory-regression-gate / deterministic-tests (push) Waiting to run
mandatory-regression-gate / protected-performance-path (push) Waiting to run
feat: add star sparkle brush preset and enhance star brush parameters
2026-07-26 03:50:02 +03:00

10470 lines
488 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u8> 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 document_open;
mod transform_dirty;
use self::document_open::load_path_into_engine;
use self::transform_dirty::preview_bounds;
use crate::ai_chat::{self, AiChatState, ChatMessage, ComfyUiState, 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 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<RecentFileEntry> {
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).
///
/// Logs a warning if the file exists but cannot be parsed so corrupted JSON
/// does not silently revert colors to defaults.
fn load_colors() -> PersistedColors {
let path = colors_path();
match std::fs::read_to_string(&path) {
Ok(json) => serde_json::from_str(&json)
.map_err(|e| {
log::warn!(
"Failed to parse persisted colors at {}: {}. Using defaults.",
path.display(),
e
);
e
})
.unwrap_or_default(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PersistedColors::default(),
Err(e) => {
log::warn!(
"Failed to read persisted colors at {}: {}",
path.display(),
e
);
PersistedColors::default()
}
}
}
/// Save colors to disk.
fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) {
let path = colors_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
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(&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,
/// Save/export error with alternative path suggestion.
SaveError,
/// Clipboard paste exceeds canvas — confirm canvas expansion.
ExpandCanvas {
paste_w: u32,
paste_h: u32,
canvas_w: u32,
canvas_h: u32,
},
/// SVG vector node editor.
SvgEditor,
}
/// Top-level application state.
pub struct HcieIcedApp {
/// All open documents.
pub documents: Vec<IcedDocument>,
/// 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,
/// Slot whose expanded sub-tool chooser remains open until toggled again.
pub open_subtool_slot: Option<usize>,
/// 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<usize>,
/// 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<iced::window::Id>,
/// Pending automated or keyboard-triggered full-viewport screenshot.
pub screenshot_request: Option<crate::cli::ScreenshotRequest>,
/// 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<FilterType>,
/// Filter parameters (JSON) for the currently selected filter.
pub filter_params: serde_json::Value,
/// Filter preview state.
pub filter_preview_active: bool,
/// Which filter categories are expanded (name → expanded).
pub filter_categories_expanded: std::collections::HashMap<String, bool>,
/// Immutable active-layer pixels used by filter and adjustment previews.
preview_baseline: Option<PreviewBaseline>,
/// Original styles restored when the Layer Style dialog is cancelled.
layer_style_baseline: Option<LayerStyleBaseline>,
/// File operation associated with the currently open native file dialog.
pending_file_operation: Option<PendingFileOperation>,
/// Document tab awaiting Save/Discard confirmation before removal.
pending_document_close: Option<usize>,
/// Dock layout state.
pub dock: DockState,
/// Shared tablet state for pressure input.
pub tablet_state: Arc<Mutex<TabletState>>,
/// 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<usize>,
/// Recently opened files (most recent first, max 20).
pub recent_files: Vec<RecentFileEntry>,
/// 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<crate::color_picker::ColorPickerTarget>,
/// 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,
/// Which sub-tab is active in the AI Assistant panel (0 = ComfyUI, 1 = AI Chat).
pub ai_assistant_tab: usize,
/// ComfyUI connection and pipeline state.
pub comfy_state: ComfyUiState,
/// Title bar search text.
pub title_search: String,
/// Whether the panel list popup is shown.
pub show_panel_list: bool,
/// Abort handle for the currently active AI response stream.
ai_chat_abort: Option<iced::task::Handle>,
/// 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<String>,
/// Line number (1-indexed) of the last error, for highlighting.
pub script_error_line: Option<usize>,
/// 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<String>,
/// AI script generator — last successful generation/copy/execution notice.
pub ai_script_notice: Option<String>,
/// 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<String>,
/// 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<usize>,
/// 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,
/// Active Brushes & Tips category filter retained while the panel redraws.
pub brush_category: crate::panels::brushes::BrushCategory,
/// Context menu screen position (x, y). None means closed.
pub canvas_context_menu: Option<(f32, f32)>,
/// Runtime build version string (e.g. `"0.1.0+build.992"`), incremented on each launch.
pub runtime_version: String,
/// 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,
/// True while the user is dragging on the color wheel; shape sync and
/// composite refresh are deferred until the drag ends to avoid per-move lag.
pub color_dragging: bool,
/// Final color sampled during the active wheel drag; committed to Recent on mouse-up.
pub pending_drag_color: Option<[u8; 4]>,
/// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration).
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
/// Cached `(kind, svg)` pairs from the engine's shape catalog for the active document.
/// Updated when the Custom Shapes panel is refreshed so `VectorDrawEnd` always uses the
/// latest catalog contents without re-scanning the shapes directory on every drag.
pub cached_custom_shapes: Vec<(String, String)>,
/// Last drag position during vector shape editing (canvas coordinates).
pub vector_drag_last: Option<(f32, f32)>,
/// Last composite bounds during drag — used to skip redundant re-renders.
pub vector_drag_last_bounds: Option<(f32, f32, f32, f32)>,
/// Last composite angle during drag — used to skip redundant re-renders.
pub vector_drag_last_angle: Option<f32>,
/// Immutable basis for the active vector drag.
pub vector_drag_session: Option<crate::vector_edit::VectorDragSession>,
/// Boolean operation shape A selector index (geometry panel).
pub bool_shape_a: Option<usize>,
/// Boolean operation shape B selector index (geometry panel).
pub bool_shape_b: Option<usize>,
/// Whether the toolbox is expanded to 2 columns (false = single column 36px, true = 2 columns 72px).
pub sidebar_expanded: bool,
/// Plugin registry for the plugin system.
pub plugin_registry: crate::plugins::registry::PluginRegistry,
/// SVG editor state (None when closed).
pub svg_editor_state: Option<crate::panels::svg_editor::SvgEditorState>,
/// Save error state (None when no error).
pub save_error_state: Option<crate::dialogs::save_error::SaveErrorState>,
}
/// 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<u8>,
}
/// Layer-style state captured when the modal editor opens.
#[derive(Debug, Clone)]
struct LayerStyleBaseline {
document_index: usize,
layer_id: u64,
styles: Vec<LayerStyle>,
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,
/// Stable full RGBA recovery image shared with the persistent shader pipeline.
/// Normal frames use packed dirty payloads; this is read only for full texture recovery.
pub composite_pixels: crate::canvas::texture_update::SharedCompositePixels,
/// Mutable composite buffer for partial copies and packed dirty-update construction.
pub(crate) composite_raw: Vec<u8>,
/// Document display name.
pub name: String,
/// Original file path, if loaded from disk.
pub source_path: Option<std::path::PathBuf>,
/// 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<hcie_engine_api::LayerInfo>,
/// 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<Option<[u32; 4]>>,
/// 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<bool>,
/// Active selection transform (floating pixels after cut/copy-paste)
pub selection_transform: Option<SelectionTransform>,
/// 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<SelectionTransform>,
/// Source pixels and placement removed when a move transform began, for exact Cancel restore.
pub transform_source: Option<SelectionTransform>,
/// Original active-layer pixels for the transform's single history transaction.
pub transform_layer_baseline: Option<Vec<u8>>,
/// Original engine selection mask restored when a transform is cancelled.
pub transform_selection_baseline: Option<Vec<u8>>,
/// Composite snapshot without the floating transform, used as the base for each preview frame.
pub transform_preview_base: Option<Vec<u8>>,
/// Internal clipboard for copy/paste operations
pub internal_clipboard: Option<SelectionTransform>,
/// Stashed paste pixels when the paste exceeds the canvas, awaiting
/// the ExpandCanvas dialog decision. Consumed on Expand or Cancel.
pub pending_paste: Option<(Vec<u8>, u32, u32)>,
/// When true, the pending_paste pixels should be placed as a new layer
/// instead of entering transform mode (set by "Paste as New Layer").
pub pending_paste_as_new_layer: bool,
/// Raw selection mask retained for transform and selection-history workflows.
pub selection_mask: Option<Arc<Vec<u8>>>,
/// Encoded R8 selection texture: zero unselected, 128 interior, 255 border.
pub selection_texture: Option<Arc<Vec<u8>>>,
/// Whether a selection engine/local mutation still needs cache synchronization.
pub selection_model_dirty: bool,
/// Whether the encoded selection texture has changed since last GPU upload.
pub selection_mask_dirty: std::cell::Cell<bool>,
/// 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<Option<Vec<u8>>>,
/// 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<TextDraft>,
/// 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<Vec<u8>>,
/// 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<usize>,
/// 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,
/// Transformed vector shape being previewed during a drag (not yet committed
/// to the engine). Kept in the document so the overlay canvas can draw it.
pub vector_drag_preview: Option<hcie_engine_api::VectorShape>,
/// True while the user is holding Shift during a rotation drag (enables snap visual feedback).
pub rotation_snap_active: bool,
/// The snapped angle (radians) currently displayed in the snap indicator overlay.
pub rotation_snap_angle: f32,
/// True while the user is holding Shift during a resize drag (enables aspect-lock visual feedback).
pub resize_snap_active: bool,
/// Cached layer thumbnails keyed by layer ID — (thumb_w, thumb_h, RGBA pixels).
/// Regenerated only for dirty layers in `refresh_panel_caches()`.
pub layer_thumbnails: std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
/// Layer IDs whose thumbnails must be regenerated after a canvas-only refresh.
/// Dirty engine flags are cleared after compositing, so this set carries the
/// invalidation until panel work is safe to perform at stroke completion.
pub pending_thumbnail_layers: std::collections::HashSet<u64>,
/// Thumbnail generation generation counter; incremented when any thumbnail
/// is regenerated so the view can detect stale cache without locking.
pub thumb_gen: u64,
}
/// 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,
/// True when each brush dab should vary its color slightly.
pub brush_color_variant: bool,
/// Magnitude of the per-dab color variation (0.0..1.0).
pub brush_variant_amount: f32,
/// Star brush base rotation angle in radians.
pub brush_angle: f32,
/// Star brush per-dab size variance (0.0..1.0).
pub brush_size_variance: f32,
/// Star brush per-dab angle variance (0.0..1.0).
pub brush_angle_variance: f32,
/// True when the brush color should cycle through the hue over stroke distance.
pub brush_cyclic_color: bool,
/// Speed of the hue cycle along the stroke (0.01..5.0).
pub brush_cyclic_speed: f32,
/// 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<hcie_engine_api::BrushPreset>,
/// Complete preset currently supplying non-style brush-tip parameters.
pub active_brush_preset: Option<hcie_engine_api::BrushPreset>,
/// 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,
brush_color_variant: false,
brush_variant_amount: 0.0,
brush_angle: 0.0,
brush_size_variance: 0.0,
brush_angle_variance: 0.0,
brush_cyclic_color: false,
brush_cyclic_speed: 0.5,
last_update_instant: std::time::Instant::now(),
imported_brushes: Vec::new(),
active_brush_preset: None,
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]),
/// Signals the start of a color-picker drag with the initial color.
ColorDragStarted([u8; 4]),
/// Signals the end of a color-picker drag; triggers deferred shape sync.
ColorDragEnded,
SwapColors,
ResetColors,
ColorTabChanged(usize),
// ── Custom shapes panel ─────────────────────────────
RefreshCustomShapes,
// ── Canvas interaction ──────────────────────────────
CanvasPointerPressed {
x: f32,
y: f32,
captured_at: std::time::Instant,
},
CanvasPointerMoved {
x: f32,
y: f32,
captured_at: std::time::Instant,
},
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,
},
/// Lightweight no-op message emitted by the canvas overlay on every
/// `CursorMoved` event. Its sole purpose is to trigger a `view()`
/// rebuild so the crosshair overlay redraws at the latest cursor
/// position during idle (non-drawing) pointer movement.
CursorOverlayRedraw,
ModifiersChanged(iced::keyboard::Modifiers),
SpacePanChanged(bool),
// ── Engine operations ───────────────────────────────
CompositeRefresh,
/// Paces visible stroke updates independently from raw pointer message frequency.
CanvasFrameTick,
/// Polled after end_stroke to commit any pending history snapshots
/// that the background thread hadn't finished yet.
CompositeRefreshPending,
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),
LayerAddMask(u64),
LayerRemoveMask(u64),
LayerToggleMask(u64),
LayerSetEditMask(bool),
LayerToggleEditMask,
// ── 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),
BrushPresetSelected(hcie_engine_api::BrushPreset),
BrushCategorySelected(crate::panels::brushes::BrushCategory),
BrushSizeChanged(f32),
BrushSizeRelative(f32),
BrushOpacityChanged(f32),
BrushHardnessChanged(f32),
BrushFlowChanged(f32),
BrushSpacingChanged(f32),
BrushColorVariantToggled(bool),
BrushVariantAmountChanged(f32),
BrushAngleChanged(f32),
BrushSizeVarianceChanged(f32),
BrushAngleVarianceChanged(f32),
BrushCyclicColorToggled(bool),
BrushCyclicSpeedChanged(f32),
BrushImportAbr,
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, 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),
VectorAngleChanged(f32),
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,
FilterCategoryToggle(String),
// ── 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,
VectorShapeMoveUp,
VectorShapeMoveDown,
VectorShapeRename(usize, String),
VectorShapeSelect(Option<usize>),
VectorShapeApply,
VectorShapeCancel,
VectorShapeFlipH,
VectorShapeFlipV,
// ── Geometry panel controls ──────────────────────────
GeometryBoundsX1(f32),
GeometryBoundsY1(f32),
GeometryBoundsX2(f32),
GeometryBoundsY2(f32),
GeometryAngleChanged(f32),
GeometryFillToggled(bool),
GeometryFillColorChanged([u8; 4]),
GeometryStrokeColorChanged([u8; 4]),
GeometryOpacityChanged(f32),
GeometryHardnessChanged(f32),
GeometryBoolShapeA(Option<usize>),
GeometryBoolShapeB(Option<usize>),
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),
/// Switch the active sub-tab in the AI Assistant panel (0 = ComfyUI, 1 = AI Chat).
AiAssistantTabChanged(usize),
/// Trigger a ComfyUI connection status check.
ComfyUiCheckStatus,
/// Update the ComfyUI server URL.
ComfyUiUrlChanged(String),
AiChatStreamChunk(String),
AiChatReasoningChunk(String),
AiChatStreamFinished(Result<String, String>),
AiChatCancel,
AiChatCopyTranscript,
// ── AI Script Generator ─────────────────────────────
AiScriptPromptChanged(String),
AiScriptProviderChanged(String),
AiScriptModelChanged(String),
AiScriptUrlChanged(String),
AiScriptGenerate,
AiScriptRun,
AiScriptCopy,
AiScriptCopyOutput,
AiScriptClear,
AiScriptResult(Result<String, String>),
// ── Clipboard ───────────────────────────────────────
CopyImage,
CutImage,
PasteImage,
PasteAsNewLayer,
CopyText(String),
PasteText,
ClipboardResult(Result<Option<String>, String>),
ImageCopied(Result<(), String>),
ImagePasted(Result<Option<(Vec<u8>, u32, u32)>, String>),
ImagePastedAsNewLayer(Result<Option<(Vec<u8>, u32, u32)>, String>),
// ── File I/O (with rfd) ─────────────────────────────
OpenFileRfd,
SaveFileAs,
FileDialogClosed(Option<std::path::PathBuf>),
// ── 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<std::path::PathBuf, String>),
SaveFile,
// ── Menu ─────────────────────────────────────────────
MenuOpen(usize),
MenuClose,
/// Update the title bar search text.
TitleSearchChanged(String),
/// Toggle the panel list popup in the title bar.
PanelListToggle,
/// 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,
/// Toggle the expanded/collapsed state of a directory tree node.
ViewerToggleNode(std::path::PathBuf),
/// Toggle between light and dark theme presets within the viewer.
ViewerThemeToggle,
// ── SVG Editor ──────────────────────────────────────
/// Open the SVG node editor for a vector SvgShape.
SvgEditorOpen {
layer_id: u64,
shape_idx: usize,
},
SvgEditorSelectPoly(usize),
SvgEditorSelectNode {
polygon: usize,
node: usize,
},
SvgEditorClearSelection,
SvgEditorMoveNode {
polygon: usize,
node: usize,
x: f32,
y: f32,
},
SvgEditorCanvasReleased,
SvgEditorCanvasAddNode {
polygon: usize,
edge: usize,
},
SvgEditorXChanged(String),
SvgEditorYChanged(String),
SvgEditorPan {
dx: f32,
dy: f32,
},
SvgEditorZoom(f32),
SvgEditorFit,
SvgEditorSnapChanged(bool),
SvgEditorGridChanged(String),
SvgEditorAddNode,
SvgEditorRemoveNode,
SvgEditorSave,
SvgEditorCancel,
// ── Toolbox expand/collapse ─────────────────────────
SidebarToggleExpanded,
}
/// Convert a filter_id string to a FilterType enum.
fn filter_id_to_type(filter_id: &str) -> FilterType {
match filter_id {
"box_blur" => FilterType::BoxBlur,
"gaussian_blur" => FilterType::GaussianBlur,
"motion_blur" => FilterType::MotionBlur,
"sharpen" | "unsharp_mask" => FilterType::UnsharpMask,
"mosaic" => FilterType::Mosaic,
"pinch" => FilterType::Pinch,
"twirl" => FilterType::Twirl,
"oil_paint" => FilterType::OilPaint,
"crystallize" => FilterType::Crystallize,
"levels" => FilterType::Levels,
"vibrance" => FilterType::Vibrance,
"exposure" => FilterType::Exposure,
"posterize" => FilterType::Posterize,
"threshold" => FilterType::Threshold,
"channel_mixer" => FilterType::ChannelMixer,
"black_and_white" | "bw" => FilterType::BlackAndWhite,
"selective_color" => FilterType::SelectiveColor,
"gamma_correction" | "gamma" => FilterType::GammaCorrection,
"extract_channel" => FilterType::ExtractChannel,
"gradient_map" => FilterType::GradientMap,
"gaussian_blur_gamma" => FilterType::GaussianBlurGamma,
"box_blur_gamma" => FilterType::BoxBlurGamma,
"median_filter" | "median" => FilterType::MedianFilter,
"dehaze" => FilterType::Dehaze,
"noise_pattern" => FilterType::NoisePattern,
_ => {
log::warn!(
"Unknown filter_id: {}, defaulting to GaussianBlur",
filter_id
);
FilterType::GaussianBlur
}
}
}
/// Default parameters for procedural texture filters.
///
/// Textures have no `FilterType` variant and no parameter dialog — they apply
/// immediately with hardcoded default params (mirrors EGUI `menus.rs:561-655`).
fn default_texture_params(filter_id: &str) -> serde_json::Value {
match filter_id {
"texture_clouds" => serde_json::json!({"scale": 1.0, "roughness": 0.5}),
"texture_grass" => serde_json::json!({"scale": 1.0, "roughness": 0.5}),
"texture_sand" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0})
}
"texture_dirt" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "darkness": 0.0})
}
"texture_water" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "depth": 0.0})
}
"texture_rain" => serde_json::json!({
"scale": 1.0, "density": 0.5, "angle_deg": 45.0, "drop_len": 10.0
}),
"texture_stone" => serde_json::json!({"scale": 1.0, "roughness": 0.5}),
"texture_bricks" => {
serde_json::json!({"mortar_thick": 1.0, "brick_w": 40.0, "brick_h": 20.0})
}
"texture_wood_oak" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0})
}
"texture_wood_pine" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0})
}
"texture_wood_dark" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0})
}
"texture_metal_steel" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0})
}
"texture_metal_gold" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0})
}
"texture_metal_copper" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0})
}
"texture_linen" => {
serde_json::json!({"scale": 1.0, "roughness": 0.5, "thread_density": 5.0})
}
"texture_glass" => {
serde_json::json!({"roughness": 0.5, "cell_size": 30.0, "tint_blue": 0.5})
}
"sky_replacement" => serde_json::json!({
"tolerance": 0.5, "feather": 0, "light_match": false, "sky_path": ""
}),
_ => serde_json::json!({}),
}
}
/// Convert a FilterType enum to a user-facing display label.
///
/// Mirrors the naming used in the Filters panel so history entries read
/// naturally (e.g. "Apply Box Blur" rather than "Apply box_blur").
fn filter_label(filter: FilterType) -> &'static str {
match filter {
FilterType::BoxBlur => "Box Blur",
FilterType::GaussianBlur => "Gaussian Blur",
FilterType::MotionBlur => "Motion Blur",
FilterType::UnsharpMask => "Unsharp Mask",
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 & White",
FilterType::SelectiveColor => "Selective Color",
FilterType::GammaCorrection => "Gamma Correction",
FilterType::ExtractChannel => "Extract Channel",
FilterType::GradientMap => "Gradient Map",
FilterType::GaussianBlurGamma => "Gamma-aware Gaussian Blur",
FilterType::BoxBlurGamma => "Gamma-aware Box Blur",
FilterType::MedianFilter => "Median Filter",
FilterType::Dehaze => "Dehaze",
FilterType::NoisePattern => "Noise Pattern",
}
}
/// 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,
color_variant: state.brush_color_variant,
variant_amount: state.brush_variant_amount,
angle: state.brush_angle,
roundness: state.brush_size_variance,
rotation_random: state.brush_angle_variance,
..BrushTip::default()
}
}
/// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`.
///
/// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with
/// all shared rendering fields copied and protocol-only time dynamics left at defaults.
/// **Side Effects / Dependencies:** None.
fn protocol_tip_from_preset(source: &hcie_engine_api::brush::BrushTip) -> BrushTip {
let mut tip = BrushTip::default();
tip.style = protocol_style_from_preset(source.style);
tip.size = source.size;
tip.opacity = source.opacity;
tip.hardness = source.hardness;
tip.spacing = source.spacing;
tip.flow = source.flow;
tip.jitter_amount = source.jitter_amount;
tip.scatter_amount = source.scatter_amount;
tip.angle = source.angle;
tip.roundness = source.roundness;
tip.spray_particle_size = source.spray_particle_size;
tip.spray_density = source.spray_density;
tip.bitmap_pixels = source.bitmap_pixels.clone();
tip.bitmap_w = source.bitmap_w;
tip.bitmap_h = source.bitmap_h;
tip.color_variant = source.color_variant;
tip.variant_amount = source.variant_amount;
tip.density = source.density;
tip.drawing_angle = source.drawing_angle;
tip.rotation_random = source.rotation_random;
tip
}
/// Maps the brush-engine preset enum to the protocol enum used by GUI tool state.
///
/// **Arguments:** `style` is a serialized preset style. **Returns:** Its protocol equivalent.
/// **Side Effects / Dependencies:** None.
fn protocol_style_from_preset(style: hcie_engine_api::brush::BrushStyle) -> BrushStyle {
use hcie_engine_api::brush::BrushStyle as PresetStyle;
match style {
PresetStyle::Round => BrushStyle::Round,
PresetStyle::Square => BrushStyle::Square,
PresetStyle::HardRound => BrushStyle::HardRound,
PresetStyle::SoftRound => BrushStyle::SoftRound,
PresetStyle::Star => BrushStyle::Star,
PresetStyle::Noise => BrushStyle::Noise,
PresetStyle::Texture => BrushStyle::Texture,
PresetStyle::Spray => BrushStyle::Spray,
PresetStyle::Pencil => BrushStyle::Pencil,
PresetStyle::Pen => BrushStyle::Pen,
PresetStyle::Calligraphy => BrushStyle::Calligraphy,
PresetStyle::Oil => BrushStyle::Oil,
PresetStyle::Charcoal => BrushStyle::Charcoal,
PresetStyle::Leaf => BrushStyle::Leaf,
PresetStyle::Rock => BrushStyle::Rock,
PresetStyle::Meadow => BrushStyle::Meadow,
PresetStyle::Wood => BrushStyle::Wood,
PresetStyle::Watercolor => BrushStyle::Watercolor,
PresetStyle::Marker => BrushStyle::Marker,
PresetStyle::Sketch => BrushStyle::Sketch,
PresetStyle::Hatch => BrushStyle::Hatch,
PresetStyle::Glow => BrushStyle::Glow,
PresetStyle::Airbrush => BrushStyle::Airbrush,
PresetStyle::Crayon => BrushStyle::Crayon,
PresetStyle::WetPaint => BrushStyle::WetPaint,
PresetStyle::InkPen => BrushStyle::InkPen,
PresetStyle::Clouds => BrushStyle::Clouds,
PresetStyle::Dirt => BrushStyle::Dirt,
PresetStyle::Tree => BrushStyle::Tree,
PresetStyle::Bristle => BrushStyle::Bristle,
PresetStyle::Mixer => BrushStyle::Mixer,
PresetStyle::Blender => BrushStyle::Blender,
PresetStyle::Bitmap => BrushStyle::Bitmap,
}
}
/// Convert a display label back to a PaneType for dock profile loading.
fn pane_type_from_label(label: &str) -> Option<crate::dock::state::PaneType> {
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),
"Custom Shapes" => Some(PaneType::CustomShapes),
_ => 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.
///
/// This includes both built-in vector shapes and custom SVG shapes loaded from
/// the engine's shape catalog (`Tool::CustomShape`), so they all share the same
/// canvas drag-to-draw interaction.
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
| Tool::CustomShape(_)
)
}
/// 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<u8>,
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 copies only that union into the stable shader recovery image.
///
/// **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 Err(error) =
doc.composite_pixels
.copy_region_from(&doc.composite_raw, canvas_width, upload_bounds)
{
log::error!("failed to stage transform preview: {error}");
doc.composite_pixels.replace(&doc.composite_raw);
doc.full_upload.replace(true);
}
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()
);
}
}
/// Rebuilds all GUI selection-rendering state from one raw mask.
///
/// **Purpose:** Makes selection texture work proportional to selection mutations rather than brush
/// refreshes. **Logic & Workflow:** Valid masks are encoded once into interior/border values and
/// cached behind `Arc`; empty or invalid masks clear all overlay state. **Arguments:** `doc` is the
/// target document and `mask` is an optional locally owned alpha mask. **Returns:** Nothing.
/// **Side Effects / Dependencies:** Updates selection caches and schedules exactly one GPU upload.
fn set_document_selection_mask(doc: &mut IcedDocument, mask: Option<Vec<u8>>) {
let width = doc.engine.canvas_width();
let height = doc.engine.canvas_height();
let valid_mask = mask.filter(|mask| mask.len() == (width * height) as usize);
if let Some(mask) = valid_mask {
let encoded = selection::state::encode_selection_texture(&mask, width, height);
if encoded.bounds.is_some() {
doc.selection_mask = Some(Arc::new(mask));
doc.selection_texture = Some(Arc::new(encoded.pixels));
doc.selection_bounds = encoded.bounds;
} else {
doc.selection_mask = None;
doc.selection_texture = None;
doc.selection_bounds = None;
}
} else {
doc.selection_mask = None;
doc.selection_texture = None;
doc.selection_bounds = None;
}
doc.selection_model_dirty = false;
doc.selection_mask_dirty.set(true);
}
/// Consumes one pending selection-cache synchronization request.
///
/// **Arguments:** `requested` is the document's mutation marker. **Returns:** Whether a rebuild must
/// run now. **Side Effects / Dependencies:** Resets the marker so ordinary composite refreshes are
/// constant-time until another selection operation explicitly marks it.
fn consume_selection_sync_request(requested: &mut bool) -> bool {
std::mem::take(requested)
}
/// Synchronizes cached selection rendering state after an engine selection mutation.
///
/// **Arguments:** `doc` is the document whose engine owns the source mask. **Returns:** Nothing.
/// **Logic & Workflow:** A clean marker is a constant-time no-op; a dirty marker clones and encodes
/// the engine mask once. **Side Effects / Dependencies:** Reads `hcie-engine-api` selection state and
/// may schedule a selection texture upload.
fn sync_document_selection_if_needed(doc: &mut IcedDocument) {
if !consume_selection_sync_request(&mut doc.selection_model_dirty) {
return;
}
let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned);
set_document_selection_mask(doc, mask);
}
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<Vec<u8>>) {
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 {
set_document_selection_mask(doc, None);
return;
};
let combined =
selection::state::combine_masks(previous.as_deref(), &new_mask, doc.selection_mode);
doc.engine.set_selection_mask(combined);
doc.selection_model_dirty = true;
}
/// 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<std::path::PathBuf>,
screenshot_request: Option<crate::cli::ScreenshotRequest>,
) -> (Self, Task<Message>) {
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: crate::canvas::texture_update::SharedCompositePixels::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,
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_texture: None,
selection_model_dirty: false,
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_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,
vector_drag_preview: None,
rotation_snap_active: false,
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
pending_thumbnail_layers: std::collections::HashSet::new(),
thumb_gen: 0,
};
// 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: [0, 0, 0, 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),
open_subtool_slot: None,
sidebar_expanded: settings.panel_layout.sidebar_expanded,
slot_last_used: vec![0; 21],
_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,
filter_categories_expanded: std::collections::HashMap::new(),
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_assistant_tab: 1, // default to AI Chat tab (EGUI defaults to ComfyUI tab 0)
comfy_state: ComfyUiState::default(),
title_search: String::new(),
show_panel_list: false,
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(),
brush_category: crate::panels::brushes::BrushCategory::All,
runtime_version: crate::build_info::runtime_build_version(),
colors_dirty: false,
color_dragging: false,
pending_drag_color: None,
vector_shapes_snapshot: None,
cached_custom_shapes: Vec::new(),
vector_drag_last: None,
vector_drag_last_bounds: None,
vector_drag_last_angle: None,
vector_drag_session: None,
bool_shape_a: None,
bool_shape_b: None,
plugin_registry: {
let mut reg = crate::plugins::registry::PluginRegistry::new();
reg.register(Box::new(
crate::plugins::built_in::debug_logger::DebugSignalLogger::new(),
));
reg
},
svg_editor_state: None,
save_error_state: 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;
}
if app
.screenshot_request
.as_ref()
.is_some_and(|request| request.svg_editor)
{
let fixture = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M 8,50 L 58,50 L 58,22 L 94,50 L 58,78 L 58,62 L 8,62 Z"/></svg>"#;
if let Ok(editable) = crate::panels::svg_editor::parse_editable(fixture) {
let mut editor = crate::panels::svg_editor::SvgEditorState {
layer_id: 0,
shape_idx: 0,
original_svg: fixture.to_string(),
editable,
selected_poly: 0,
selected_node: None,
zoom: 1.0,
pan: iced::Vector::ZERO,
x_input: String::new(),
y_input: String::new(),
snap_to_grid: true,
grid_size: 5.0,
grid_input: "5".to_string(),
};
editor.select_node(0, 3);
app.svg_editor_state = Some(editor);
app.active_dialog = ActiveDialog::SvgEditor;
}
}
// 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
.replace(&app.documents[0].composite_raw);
let selection = app.documents[0]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
set_document_selection_mask(&mut app.documents[0], selection);
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
}
/// Build a writable fallback path when a save fails — `~/<stem>_copy.<ext>`
/// (home dir, else temp dir). Mirrors EGUI's `utils.rs:suggest_alternative_path`.
fn suggest_alternative_path(original: &std::path::Path) -> std::path::PathBuf {
let stem = original
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
let ext = original
.extension()
.and_then(|e| e.to_str())
.unwrap_or("hcie");
let file_name = format!("{}_copy.{}", stem, ext);
if let Some(home) = dirs::home_dir() {
let candidate = home.join(&file_name);
if candidate.parent().map(|p| p.exists()).unwrap_or(false) {
return candidate;
}
}
std::env::temp_dir().join(file_name)
}
/// 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<Message> {
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.documents[next].full_upload.replace(true);
self.documents[next].selection_mask_dirty.set(true);
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_bounds = None;
self.vector_drag_last_angle = None;
if let Some(doc) = self.documents.get_mut(self.active_doc) {
doc.vector_drag_preview = None;
doc.full_upload.replace(true);
doc.selection_mask_dirty.set(true);
}
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;
if let Some(doc) = self.documents.get_mut(self.active_doc) {
doc.vector_drag_preview = None;
doc.full_upload.replace(true);
doc.selection_mask_dirty.set(true);
}
}
}
/// Returns true if `color` is within the proximity threshold of `last`,
/// meaning it is visually very similar and should be skipped to prevent
/// slider/wheel drag noise from flooding the recent colors list.
///
/// The threshold is ≤2 units per RGBA channel (unsigned comparison).
pub fn is_proximate_to_last(last: &[u8; 4], color: &[u8; 4]) -> bool {
let channel_diff = |a: u8, b: u8| -> u16 { (a as i16 - b as i16).unsigned_abs() as u16 };
channel_diff(last[0], color[0]) <= 2
&& channel_diff(last[1], color[1]) <= 2
&& channel_diff(last[2], color[2]) <= 2
}
impl HcieIcedApp {
/// Add a color to the recent colors list (most recent first, max 40).
///
/// Immediately persists to disk so committed colors survive a crash or non-graceful
/// shutdown. Color-wheel movement never calls this method; the final sampled color is
/// committed once by `ColorDragEnded`.
///
/// Proximity dedup: if the new color is within the proximity threshold of the most recent
/// entry — checked by `is_proximate_to_last` — it is skipped. This prevents slider and
/// wheel drag noise from flooding the recent colors list with near-identical entries.
fn add_recent_color(&mut self, color: [u8; 4]) {
// Skip if visually very similar to the most recent entry (proximity dedup).
if let Some(last) = self.recent_colors.first() {
if is_proximate_to_last(last, &color) {
return;
}
}
self.recent_colors.retain(|c| *c != color);
self.recent_colors.insert(0, color);
self.recent_colors.truncate(40);
// Write immediately unless we are in the middle of a colour-wheel
// drag (where callers are responsible for the final flush).
if !self.color_dragging {
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
}
}
/// Refresh the canvas 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_canvas_if_needed(&mut self) {
let doc = &mut self.documents[self.active_doc];
if doc.engine.is_composite_dirty() {
let refresh_start = std::time::Instant::now();
for layer in &doc.cached_layers {
if doc.engine.is_layer_dirty(layer.id) {
doc.pending_thumbnail_layers.insert(layer.id);
}
}
let composite_start = std::time::Instant::now();
let (dirty_rect, ptr, len) = doc.engine.render_composite_region();
crate::canvas::perf::record_duration(
"render_composite_region",
composite_start.elapsed(),
);
if len > 0 && !ptr.is_null() {
let staging_start = std::time::Instant::now();
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) {
crate::canvas::perf::record_dirty_region(rect);
let w = doc.engine.canvas_width() 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 dst_raw_ptr = doc.composite_raw.as_mut_ptr();
for y in y0..y1 {
let offset = (y * w + x0) * 4;
std::ptr::copy_nonoverlapping(
ptr.add(offset),
dst_raw_ptr.add(offset),
row_bytes,
);
}
}
if let Err(error) = doc.composite_pixels.copy_region_from(
&doc.composite_raw,
w as u32,
rect,
) {
log::error!("failed to stage dirty composite region: {error}");
doc.composite_pixels.replace(&doc.composite_raw);
doc.full_upload.replace(true);
}
}
let current = doc.dirty_region.replace(None);
doc.dirty_region.replace(Some(union_regions(current, rect)));
} else {
// Full update
crate::canvas::perf::record_dirty_region([
0,
0,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
]);
unsafe {
std::ptr::copy_nonoverlapping(ptr, doc.composite_raw.as_mut_ptr(), len);
}
doc.composite_pixels.replace(&doc.composite_raw);
doc.full_upload.replace(true);
doc.dirty_region.replace(None);
}
doc.render_generation = doc.render_generation.wrapping_add(1);
crate::canvas::perf::record_duration("cpu_staging", staging_start.elapsed());
}
doc.engine.clear_dirty_flags();
crate::canvas::perf::record_duration("canvas_refresh", refresh_start.elapsed());
}
sync_document_selection_if_needed(doc);
}
/// Refresh panel caches after canvas work has completed.
///
/// Layer pixel copies and history description allocation are intentionally
/// excluded from the active-stroke path and run only for completed document
/// operations or stroke release.
fn refresh_panel_caches(&mut self) {
let refresh_start = std::time::Instant::now();
let doc = &mut self.documents[self.active_doc];
doc.cached_layers = doc.engine.layer_infos();
let max_dim = 48u32;
doc.layer_thumbnails
.retain(|id, _| doc.cached_layers.iter().any(|l| l.id == *id));
let mut thumbnails_changed = false;
for info in &doc.cached_layers {
if info.width == 0 || info.height == 0 {
continue;
}
let cached = doc.layer_thumbnails.contains_key(&info.id);
let dirty = doc.pending_thumbnail_layers.contains(&info.id)
|| doc.engine.is_layer_dirty(info.id);
if cached && !dirty {
continue;
}
if let Some(px) = doc.engine.get_layer_pixels(info.id) {
if px.len() >= (info.width * info.height * 4) as usize {
let aspect = info.height as f32 / info.width as f32;
let th = ((max_dim as f32) * aspect).round().max(1.0) as u32;
let thumb = crate::panels::thumbnails::generate_thumbnail(
&px,
info.width,
info.height,
max_dim,
);
doc.layer_thumbnails.insert(info.id, (max_dim, th, thumb));
doc.pending_thumbnail_layers.remove(&info.id);
thumbnails_changed = true;
}
}
}
if thumbnails_changed {
doc.thumb_gen = doc.thumb_gen.wrapping_add(1);
}
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();
crate::canvas::perf::record_duration("panel_cache_refresh", refresh_start.elapsed());
}
/// Refresh both the visible canvas and the document panel caches.
///
/// This preserves the existing behavior for non-interactive operations while
/// allowing active drawing to call `refresh_canvas_if_needed()` directly.
fn refresh_composite_if_needed(&mut self) {
self.refresh_canvas_if_needed();
self.refresh_panel_caches();
}
/// Opens a file in a newly allocated editor tab.
///
/// **Purpose:** Prevents file-open entry points from replacing the active
/// document engine. **Logic & Workflow:** Creates a disposable 1x1 document,
/// loads the selected path into its engine, activates and initializes it on
/// success, or removes it and restores the previous tab on failure.
/// **Arguments:** `path` is the file to load and `add_to_recent` controls
/// recent-file persistence. **Returns:** The new document index or an engine
/// error string. **Side Effects / Dependencies:** Reads the file, appends or
/// removes one document, updates GPU upload caches, and may persist recents.
fn open_path_in_new_tab(
&mut self,
path: std::path::PathBuf,
add_to_recent: bool,
) -> Result<usize, String> {
let previous_active = self.active_doc;
let previous_count = self.documents.len();
let _ = self.update(Message::NewDocument(1, 1));
let document_index = self.documents.len() - 1;
if let Err(error) = load_path_into_engine(&mut self.documents[document_index].engine, &path)
{
self.documents.remove(document_index);
self.active_doc = previous_active.min(self.documents.len().saturating_sub(1));
debug_assert_eq!(self.documents.len(), previous_count);
return Err(error);
}
self.show_welcome = false;
self.active_doc = document_index;
let document = &mut self.documents[document_index];
document.engine.pre_tile_all_layers();
document.selection_model_dirty = true;
document.name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
document.source_path = Some(path.clone());
document.modified = false;
document.composite_raw = document.engine.get_composite_pixels();
document.composite_pixels.replace(&document.composite_raw);
document.render_generation = document.render_generation.wrapping_add(1);
document.full_upload.replace(true);
if add_to_recent {
self.add_recent_file(&path);
}
self.refresh_composite_if_needed();
Ok(document_index)
}
/// 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, style, and cyclic color.
fn apply_brush_params(&mut self) {
let mut tip = self
.tool_state
.active_brush_preset
.as_ref()
.map(|preset| protocol_tip_from_preset(&preset.tip))
.unwrap_or_else(|| build_brush_tip(&self.tool_state));
tip.style = self.tool_state.brush_style;
tip.size = self.tool_state.brush_size;
tip.opacity = self.tool_state.brush_opacity;
tip.hardness = self.tool_state.brush_hardness;
tip.spacing = self.tool_state.brush_spacing;
// Cyclic color is handled by engine state, not the tip, but preview rendering
// also reads the tip's time dynamics. Synchronize both here.
tip.time_enabled = self.tool_state.brush_cyclic_color;
tip.time_size_start = 1.0;
tip.time_size_end = 1.0;
tip.time_opacity_start = 1.0;
tip.time_opacity_end = 1.0;
tip.time_angle_start = 0.0;
tip.time_angle_end = 0.0;
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;
}
_ => {}
}
let engine = &mut self.documents[self.active_doc].engine;
engine.set_brush_tip(tip);
engine.set_cyclic_color(self.tool_state.brush_cyclic_color);
engine.set_cyclic_speed(self.tool_state.brush_cyclic_speed);
}
/// Push the current SVG editor model into the active vector shape so the
/// main canvas reflects node edits immediately. The public engine API marks
/// the vector layer dirty but does not create a history snapshot here;
/// this is intentionally a live-preview operation.
fn sync_svg_editor_preview(&mut self) {
let Some((layer_id, shape_idx, svg)) = self
.svg_editor_state
.as_ref()
.map(|state| (state.layer_id, state.shape_idx, state.editable.to_svg()))
else {
return;
};
self.documents[self.active_doc]
.engine
.set_vector_shape_svg(layer_id, shape_idx, &svg);
self.refresh_composite_if_needed();
}
// ── Update ──────────────────────────────────────────
/// Handle a message and return an optional command.
pub fn update(&mut self, message: Message) -> Task<Message> {
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;
}
// Dispatch relevant messages to the plugin system before the match consumes `message`
let _plugin_actions = crate::plugins::integration::try_dispatch(
&mut self.plugin_registry,
&message,
&mut self.tool_state,
);
match message {
Message::ToolSelected(tool) => {
log::info!("Tool selected: {:?}", tool);
self.tool_state.active_tool = tool;
if tool == Tool::Brush {
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
}
// When the user picks a custom shape, reveal the Custom Shapes
// panel and keep the active tool slot on the vector-shapes slot
// so the sidebar highlight stays coherent.
if matches!(tool, Tool::CustomShape(_)) {
self.dock
.reopen_pane(crate::dock::state::PaneType::CustomShapes);
if let Some(slot_idx) = crate::sidebar::slot_for_tool(tool) {
self.active_tool_slot = slot_idx;
}
}
// 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) => {
let same_slot = self.active_tool_slot == slot_idx;
self.active_tool_slot = slot_idx;
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
let selected_tool = if crate::sidebar::tool_slots().get(slot_idx).is_some() {
tool
} else {
primary_tool
};
self.tool_state.active_tool = selected_tool;
if selected_tool == Tool::Brush {
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
}
let supports_popup = crate::sidebar::tool_slots()
.get(slot_idx)
.is_some_and(|slot| slot.tools.len() > 1);
if same_slot && supports_popup {
self.open_subtool_slot = if self.open_subtool_slot == Some(slot_idx) {
None
} else {
Some(slot_idx)
};
}
if matches!(selected_tool, Tool::CustomShape(_)) {
self.dock
.reopen_pane(crate::dock::state::PaneType::CustomShapes);
}
}
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.open_subtool_slot != Some(slot_idx);
self.open_subtool_slot = opening.then_some(slot_idx);
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();
}
if opening {
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
if tool == Tool::Brush {
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
}
if matches!(tool, Tool::CustomShape(_)) {
self.dock
.reopen_pane(crate::dock::state::PaneType::CustomShapes);
}
}
}
}
Message::OverlayOutsideClick => {
if self.dock.active_auto_hide.take().is_some() {
return Task::none();
}
self.active_menu = None;
}
Message::FgColorChanged(color) => {
self.fg_color = color;
self.active_document_mut().engine.set_color(color);
if self.color_dragging {
self.pending_drag_color = Some(color);
} else {
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;
if !self.color_dragging {
self.add_recent_color(color);
self.colors_dirty = true;
}
if !self.color_dragging {
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
}
}
Message::ColorDragStarted(initial_color) => {
self.color_dragging = true;
self.pending_drag_color = Some(initial_color);
self.fg_color = initial_color;
self.active_document_mut().engine.set_color(initial_color);
}
Message::ColorDragEnded => {
self.color_dragging = false;
if let Some(final_color) = self.pending_drag_color.take() {
self.add_recent_color(final_color);
}
self.colors_dirty = false;
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::RefreshCustomShapes => {
// Refresh the active document's shape catalog and update the
// cached shape list used for drawing custom shapes on the canvas.
let doc = &mut self.documents[self.active_doc];
doc.engine.shape_catalog.refresh();
self.cached_custom_shapes = doc
.engine
.shape_catalog
.all()
.iter()
.map(|e| (e.kind.clone(), e.svg.clone()))
.collect();
}
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, captured_at } => {
// 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 width = self.documents[self.active_doc].engine.canvas_width();
let height = self.documents[self.active_doc].engine.canvas_height();
if canvas_x >= 0.0
&& canvas_y >= 0.0
&& (canvas_x as u32) < width
&& (canvas_y as u32) < height
{
let index = ((canvas_y as u32 * width + canvas_x as u32) * 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 => {
crate::canvas::perf::begin_stroke();
crate::canvas::perf::record_render_input(captured_at);
// 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.refresh_canvas_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);
self.add_recent_color(color);
self.colors_dirty = true;
}
}
}
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_model_dirty = true;
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.documents[self.active_doc].selection_model_dirty = true;
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();
set_document_selection_mask(doc, Some(mask_data.clone()));
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
| Tool::CustomShape(_) => {
// Start vector shape drawing (built-in + custom SVG shapes 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, captured_at } => {
// 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 w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
if x >= 0.0 && y >= 0.0 && (x as u32) < w && (y as u32) < h {
let pixels = &self.documents[self.active_doc].composite_raw;
let idx = ((y as u32 * w + x as u32) * 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);
self.add_recent_color(color);
self.colors_dirty = true;
}
}
} 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);
crate::canvas::perf::record_render_input(captured_at);
let engine_start = std::time::Instant::now();
self.documents[self.active_doc]
.engine
.draw_pen_segment(layer_id, last_x, last_y, x, y, pressure);
crate::canvas::perf::record_duration(
"engine_draw",
engine_start.elapsed(),
);
self.tool_state.last_stroke_pos = Some((x, y));
}
return Task::none();
}
let spacing: f32 = match self.tool_state.active_tool {
Tool::Spray => (self.tool_state.brush_size * 0.25).max(2.0),
Tool::Brush | Tool::Eraser => {
(self.tool_state.brush_size * 0.12).max(1.0)
}
_ => 1.0_f32,
};
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 {
crate::canvas::perf::record_render_input(captured_at);
let engine_start = std::time::Instant::now();
self.documents[self.active_doc]
.engine
.stroke_to(layer_id, x, y, pressure);
crate::canvas::perf::record_duration(
"engine_draw",
engine_start.elapsed(),
);
self.tool_state.last_stroke_pos = Some((x, y));
}
} else {
crate::canvas::perf::record_render_input(captured_at);
let engine_start = std::time::Instant::now();
self.documents[self.active_doc]
.engine
.stroke_to(layer_id, x, y, 1.0);
crate::canvas::perf::record_duration(
"engine_draw",
engine_start.elapsed(),
);
self.tool_state.last_stroke_pos = Some((x, y));
self.refresh_canvas_if_needed();
}
}
} // 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.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();
crate::canvas::perf::request_finish_stroke();
// If the background thread hasn't finished yet, schedule
// a delayed poll so the history entry is committed.
if self.documents[self.active_doc].engine.has_pending_history() {
return Task::perform(
async {
tokio::time::sleep(std::time::Duration::from_millis(8)).await;
},
|_| Message::CompositeRefreshPending,
);
}
} // 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 } => {
crate::canvas::perf::record_idle_pointer_message(false);
self.canvas_cursor_pos = Some((x, y));
}
// No-op — this message exists solely to trigger a view() rebuild
// so the crosshair overlay redraws at the latest cursor position.
// See Strategy 1 in the cursor lag fix.
Message::CursorOverlayRedraw => {}
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::CanvasFrameTick | Message::CompositeRefresh => {
self.refresh_canvas_if_needed();
}
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_model_dirty = 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();
}
_ => unreachable!(),
}
self.refresh_composite_if_needed();
}
// Poll pending background history commits. The brush engine's
// end_stroke runs a background thread that may not finish before
// the first commit_pending_history() call. This message retries
// at 16 ms intervals until the queue drains.
Message::CompositeRefreshPending => {
let doc = &mut self.documents[self.active_doc];
let committed = doc.engine.commit_pending_history();
if committed && doc.engine.has_pending_history() {
// Background thread still producing — schedule another poll.
return Task::perform(
async {
tokio::time::sleep(std::time::Duration::from_millis(16)).await;
},
|_| Message::CompositeRefreshPending,
);
}
// Canvas pixels were already staged on release; only panel metadata changed.
self.refresh_panel_caches();
}
// ── 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;
doc.vector_drag_preview = 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::LayerAddMask(id) => {
self.documents[self.active_doc].engine.add_layer_mask(id);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerRemoveMask(id) => {
self.documents[self.active_doc].engine.remove_layer_mask(id);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleMask(id) => {
self.documents[self.active_doc].engine.toggle_layer_mask(id);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerSetEditMask(editing) => {
self.documents[self.active_doc]
.engine
.set_editing_mask(editing);
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
}
Message::LayerToggleEditMask => {
self.documents[self.active_doc].engine.toggle_editing_mask();
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
}
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;
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
}
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) => {
// The layer list is shown front-to-back: the top/front layer is first.
// "Move up" therefore means toward the front of the canvas, which is
// a smaller array index in engine layer order (back is index 0).
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) => {
// "Move down" means toward the back of the canvas, which is a larger
// array index in engine layer order.
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) => {
crate::canvas::perf::record_idle_pointer_message(true);
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, &regions,
)
.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, &regions,
)
.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;
self.tool_state.active_brush_preset = None;
}
Message::BrushPresetSelected(preset) => {
log::info!("Brush preset selected: {}", preset.name);
self.tool_state.brush_style = protocol_style_from_preset(preset.style);
self.tool_state.brush_size = preset.tip.size;
self.tool_state.brush_opacity = preset.tip.opacity;
self.tool_state.brush_hardness = preset.tip.hardness;
self.tool_state.brush_spacing = preset.tip.spacing;
self.tool_state.active_brush_preset = Some(preset);
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushCategorySelected(category) => {
self.brush_category = category;
}
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::BrushColorVariantToggled(variant) => {
self.tool_state.brush_color_variant = variant;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushVariantAmountChanged(amount) => {
self.tool_state.brush_variant_amount = amount;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushAngleChanged(angle) => {
self.tool_state.brush_angle = angle;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushSizeVarianceChanged(variance) => {
self.tool_state.brush_size_variance = variance;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushAngleVarianceChanged(variance) => {
self.tool_state.brush_angle_variance = variance;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushCyclicColorToggled(enabled) => {
self.tool_state.brush_cyclic_color = enabled;
self.documents[self.active_doc]
.engine
.set_cyclic_color(enabled);
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushCyclicSpeedChanged(speed) => {
self.tool_state.brush_cyclic_speed = speed;
self.documents[self.active_doc]
.engine
.set_cyclic_speed(speed);
self.settings.update_from_tool_state(&self.tool_state);
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);
self.refresh_composite_if_needed();
let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::VectorOpacityChanged(o) => {
self.settings.tool_settings.vector_opacity = o;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::VectorFillToggled(f) => {
self.settings.tool_settings.vector_fill = f;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::VectorRadiusChanged(r) => {
self.settings.tool_settings.vector_radius = r;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::VectorPointsChanged(p) => {
self.settings.tool_settings.vector_points = p;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::VectorSidesChanged(s) => {
self.settings.tool_settings.vector_sides = s;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::VectorAngleChanged(angle_deg) => {
self.settings.tool_settings.vector_angle = angle_deg;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
let _ = self.settings.save();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
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.dock.reopen_pane(crate::dock::state::PaneType::Filters);
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();
}
// Capture the current layer pixels before applying so the
// operation can be undone from the History panel.
let doc = &mut self.documents[self.active_doc];
let active_idx = doc.engine.active_layer_index();
let before_pixels = doc.engine.get_active_layer_pixels();
let filter_name = filter_label(filter);
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());
// Always push a history snapshot after applying the filter so
// the operation is visible in the History panel and undo works.
if let Some(before) = before_pixels {
let history_name = format!("Apply {}", filter_name);
let doc = &mut self.documents[self.active_doc];
doc.engine
.push_active_layer_snapshot(before, active_idx, &history_name);
}
// Refresh cached history list so the new entry appears.
let doc = &mut self.documents[self.active_doc];
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();
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);
}
}
Message::FilterCategoryToggle(category) => {
let entry = self
.filter_categories_expanded
.entry(category)
.or_insert(true);
*entry = !*entry;
}
// ── 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::SvgEditor) {
return self.update(Message::SvgEditorCancel);
}
if matches!(
self.active_dialog,
ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation
) && self.restore_preview()
{
self.refresh_composite_if_needed();
}
if matches!(self.active_dialog, ActiveDialog::SaveError) {
self.save_error_state = None;
}
// ExpandCanvas -> cancel expansion; paste proceeds as-is
// (centered on the unchanged canvas, cropping on commit, matching
// EGUI's "paste proceeds as-is" cancel behavior).
if matches!(self.active_dialog, ActiveDialog::ExpandCanvas { .. }) {
let doc = &mut self.documents[self.active_doc];
let as_new_layer = doc.pending_paste_as_new_layer;
if let Some((pixels, w, h)) = doc.pending_paste.take() {
doc.pending_paste_as_new_layer = false;
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
if as_new_layer {
// Paste as new layer: create layer with centered (possibly clipped) pixels.
let centered = selection::clipboard::centered_layer_pixels(
&pixels, w, h, canvas_w, canvas_h,
);
let id = doc.engine.add_layer("Pasted Layer");
doc.engine.set_layer_pixels(id, centered);
doc.engine.set_active_layer(id);
doc.modified = true;
self.refresh_composite_if_needed();
self.active_dialog = ActiveDialog::None;
self.pending_document_close = None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// Standard paste: enter transform mode.
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);
}
self.active_dialog = ActiveDialog::None;
self.pending_document_close = None;
return Task::none();
}
self.active_dialog = ActiveDialog::None;
self.pending_document_close = None;
}
Message::DialogCloseSave => {
// SaveError -> save to suggested path
if matches!(self.active_dialog, ActiveDialog::SaveError) {
if let Some(state) = self.save_error_state.take() {
let path = state.suggestion.clone();
let op = state.operation.clone();
match self.save_document_to(self.active_doc, &path) {
Ok(()) => {
self.finish_successful_save(self.active_doc, path);
log::info!("{} saved to suggested path", op);
}
Err(e) => {
log::error!("{} to suggested path failed: {}", op, e);
self.save_error_state =
Some(crate::dialogs::save_error::SaveErrorState {
operation: op,
path: path.clone(),
error: e.clone(),
suggestion: Self::suggest_alternative_path(&path),
});
self.active_dialog = ActiveDialog::SaveError;
return Task::none();
}
}
}
self.active_dialog = ActiveDialog::None;
return Task::none();
}
// ExpandCanvas -> expand document to fit paste, then create transform
if let ActiveDialog::ExpandCanvas {
paste_w, paste_h, ..
} = self.active_dialog
{
let doc = &mut self.documents[self.active_doc];
let as_new_layer = doc.pending_paste_as_new_layer;
doc.engine.resize_canvas(paste_w, paste_h);
doc.engine.selection_clear();
set_document_selection_mask(doc, None);
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
if let Some((pixels, w, h)) = doc.pending_paste.take() {
doc.pending_paste_as_new_layer = false;
if as_new_layer {
// Paste as new layer: create layer with centered pixels on expanded canvas.
let centered = selection::clipboard::centered_layer_pixels(
&pixels, w, h, canvas_w, canvas_h,
);
let id = doc.engine.add_layer("Pasted Layer");
doc.engine.set_layer_pixels(id, centered);
doc.engine.set_active_layer(id);
doc.modified = true;
} else {
// Standard paste: enter transform mode.
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);
}
}
self.active_dialog = ActiveDialog::None;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// SVG editor -> save and close
if matches!(self.active_dialog, ActiveDialog::SvgEditor) {
return self.update(Message::SvgEditorSave);
}
// Default: save-before-close
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}");
self.save_error_state =
Some(crate::dialogs::save_error::SaveErrorState {
operation: "Save".to_string(),
path: path.clone(),
error: error.clone(),
suggestion: Self::suggest_alternative_path(&path),
});
self.active_dialog = ActiveDialog::SaveError;
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 => {
// SVG editor -> close without saving
if matches!(self.active_dialog, ActiveDialog::SvgEditor) {
return self.update(Message::SvgEditorCancel);
}
// SaveError -> retry
if matches!(self.active_dialog, ActiveDialog::SaveError) {
self.save_error_state = None;
self.active_dialog = ActiveDialog::None;
return self.update(Message::SaveFile);
}
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;
// Guard against 0×N documents that would crash wgpu texture allocation.
if w == 0 || h == 0 {
log::warn!(
"Refusing to create a {}×{} document: dimensions must be at least 1",
w,
h
);
return Task::none();
}
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: crate::canvas::texture_update::SharedCompositePixels::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,
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_texture: None,
selection_model_dirty: false,
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_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,
vector_drag_preview: None,
rotation_snap_active: false,
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
pending_thumbnail_layers: std::collections::HashSet::new(),
thumb_gen: 0,
};
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),
"Erode" => self.documents[self.active_doc].engine.selection_shrink(v),
"Fade" => self.documents[self.active_doc]
.engine
.selection_feather(v as f32),
_ => {}
},
_ => {}
}
// Selection operations mutate the existing mask in place, so force overlay cache
// synchronization before the next marching-ants frame.
self.documents[self.active_doc].selection_model_dirty = true;
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.selection_clear();
set_document_selection_mask(&mut self.documents[self.active_doc], None);
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.selection_clear();
set_document_selection_mask(&mut self.documents[self.active_doc], None);
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);
// 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);
self.documents[self.active_doc].engine.selection_all();
self.documents[self.active_doc].selection_model_dirty = true;
self.refresh_composite_if_needed();
}
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();
set_document_selection_mask(doc, None);
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::SelectInverse => {
self.documents[self.active_doc].engine.selection_invert();
self.documents[self.active_doc].selection_model_dirty = true;
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.documents[self.active_doc].selection_model_dirty = true;
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();
set_document_selection_mask(doc, None);
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());
set_document_selection_mask(doc, Some(mask));
}
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);
}
// Use cached catalog snapshot if available; otherwise read once from the
// active document's shape catalog. `cached_custom_shapes` is populated/updated
// by `RefreshCustomShapes` so drawing remains consistent with the panel.
let custom_shapes: Vec<(String, String)> =
if self.cached_custom_shapes.is_empty() {
self.documents
.get(self.active_doc)
.map(|d| {
d.engine
.shape_catalog
.all()
.iter()
.map(|e| (e.kind.clone(), e.svg.clone()))
.collect()
})
.unwrap_or_default()
} else {
self.cached_custom_shapes.clone()
};
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 svg_angle = self.settings.tool_settings.vector_angle.to_radians();
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 = self.bg_color;
let shape = match self.tool_state.active_tool {
Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect {
name: String::new(),
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 {
name: String::new(),
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 {
name: String::new(),
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 => {
let mut p = std::collections::HashMap::new();
p.insert("thick".to_string(), 0.0);
match hcie_engine_api::create_vector_shape("arrow", x0, y0, x1, y1, &p)
{
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: x0,
y1: y0,
x2: x1,
y2: y1,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorStar => {
let mut p = std::collections::HashMap::new();
p.insert("points".to_string(), points as f32);
p.insert("inner_radius".to_string(), 0.5);
let (sx1, sy1, sx2, sy2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"star", sx1, sy1, sx2, sy2, &p,
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: sx1,
y1: sy1,
x2: sx2,
y2: sy2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorPolygon => Some(hcie_engine_api::VectorShape::Polygon {
name: String::new(),
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 => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"rhombus",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorCylinder => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"cylinder",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorHeart => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"heart",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorBubble => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"bubble",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorGear => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"gear",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorCross => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"cross",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorCrescent => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"crescent",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorBolt => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"bolt",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::VectorArrow4 => {
let (nx1, ny1, nx2, ny2) =
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
match hcie_engine_api::create_vector_shape(
"arrow4",
nx1,
ny1,
nx2,
ny2,
&std::collections::HashMap::new(),
) {
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
kind,
svg,
x1: nx1,
y1: ny1,
x2: nx2,
y2: ny2,
stroke,
color,
fill_color,
fill,
angle: 0.0,
opacity,
hardness: 0.5,
})
}
_ => unreachable!(),
}
}
Tool::CustomShape(idx) => {
let entry = custom_shapes.get(idx as usize);
match entry {
Some((kind, svg)) => Some(hcie_engine_api::VectorShape::SvgShape {
name: String::new(),
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
kind: kind.clone(),
svg: svg.clone(),
stroke,
color,
fill_color: color,
fill: true,
angle: 0.0,
opacity,
hardness: 0.5,
}),
None => None,
}
}
_ => None,
};
if let Some(mut shape) = shape {
// For arrow shapes, derive the angle from the drag
// direction so the arrow points along the drawn line.
if matches!(
self.tool_state.active_tool,
hcie_engine_api::Tool::VectorArrow
) {
let drag_angle = (y1 - y0).atan2(x1 - x0);
let drag_angle = if self.modifiers.shift() {
crate::vector_edit::snap_to_15deg(drag_angle)
} else {
drag_angle
};
shape.set_angle(drag_angle);
} else if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
shape.set_angle(svg_angle);
}
engine.add_vector_shape(shape);
self.documents[self.active_doc].modified = true;
// Reset angle so every new drawing starts at 0°.
self.settings.tool_settings.vector_angle = 0.0;
}
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<usize> = 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;
doc.vector_drag_preview = 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;
doc.vector_drag_preview = 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;
self.documents[self.active_doc].vector_drag_preview = None;
return Task::none();
}
let shape = crate::vector_edit::transform_shape(
&session,
(x, y),
self.modifiers.shift(),
self.modifiers.alt(),
self.modifiers.shift(),
);
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle();
// Keep the transformed shape in a lightweight overlay preview
// instead of mutating the engine on every pointer event. The
// expensive vector rasterization and full-layer composite only
// happen once when the drag ends.
self.documents[self.active_doc].vector_drag_preview = Some(shape);
self.documents[self.active_doc].modified = true;
self.vector_drag_last = Some((x, y));
self.vector_drag_last_bounds = Some((x1, y1, x2, y2));
self.vector_drag_last_angle = Some(angle);
self.settings.tool_settings.vector_angle = angle.to_degrees();
// Track snap state for visual overlay feedback
let is_rotate =
matches!(session.handle, hcie_engine_api::VectorEditHandle::Rotate);
let is_resize = matches!(
session.handle,
hcie_engine_api::VectorEditHandle::TopLeft
| hcie_engine_api::VectorEditHandle::TopRight
| hcie_engine_api::VectorEditHandle::BottomLeft
| hcie_engine_api::VectorEditHandle::BottomRight
);
self.documents[self.active_doc].rotation_snap_active =
is_rotate && self.modifiers.shift();
self.documents[self.active_doc].rotation_snap_angle = angle;
self.documents[self.active_doc].resize_snap_active =
is_resize && self.modifiers.shift();
}
}
Message::VectorSelectDragEnd => {
if self.vector_drag_last.is_some() {
// Commit the previewed transform to the engine in one
// transaction, avoiding per-event rasterization.
if let Some(session) = self.vector_drag_session.as_ref() {
if let Some(shape) =
self.documents[self.active_doc].vector_drag_preview.take()
{
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_bounds = None;
self.vector_drag_last_angle = None;
self.vector_drag_session = None;
self.documents[self.active_doc].vector_drag_preview = None;
self.documents[self.active_doc].vector_edit_handle =
hcie_engine_api::VectorEditHandle::None;
self.documents[self.active_doc].rotation_snap_active = false;
self.documents[self.active_doc].resize_snap_active = false;
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.documents[self.active_doc].vector_drag_preview = None;
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::VectorShapeMoveUp => {
// Move the selected shape toward the front of the Z-order (higher index).
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 count = self.documents[self.active_doc]
.engine
.active_vector_shapes()
.map_or(0, |s| s.len());
if idx + 1 < count {
self.documents[self.active_doc].engine.reorder_vector_shape(
layer_id,
idx,
idx + 1,
);
self.documents[self.active_doc].selected_vector_shape = Some(idx + 1);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
}
Message::VectorShapeMoveDown => {
// Move the selected shape toward the back of the Z-order (lower index).
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
if idx > 0 {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc].engine.reorder_vector_shape(
layer_id,
idx,
idx - 1,
);
self.documents[self.active_doc].selected_vector_shape = Some(idx - 1);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
}
Message::VectorShapeRename(idx, new_name) => {
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) {
shape.set_name(new_name);
self.documents[self.active_doc].modified = true;
}
}
}
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;
self.documents[self.active_doc].vector_drag_preview = None;
}
// ── Vector shape apply / cancel / flip ──────
Message::VectorShapeApply => {
// Commit any pending drag preview to the engine before deselecting,
// then clear the snapshot so Cancel cannot revert.
let need_refresh = {
let doc = &mut self.documents[self.active_doc];
if let Some(shape) = doc.vector_drag_preview.take() {
let shape_idx = doc.selected_vector_shape;
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle();
let layer_id = doc.engine.active_layer_id();
if let Some(idx) = shape_idx {
doc.engine
.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, y2);
doc.engine.set_vector_shape_angle(layer_id, idx, angle);
}
doc.engine.mark_composite_dirty();
doc.engine.is_composite_dirty()
} else {
false
}
};
if need_refresh {
self.refresh_composite_if_needed();
}
self.documents[self.active_doc].selected_vector_shape = None;
self.documents[self.active_doc].vector_cycle_index = 0;
self.documents[self.active_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;
doc.vector_drag_preview = None;
self.vector_drag_last = None;
self.vector_drag_session = None;
doc.engine.mark_composite_dirty();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
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::<f32>() / 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::<f32>() / 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::GeometryAngleChanged(angle_deg) => {
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_angle(layer_id, idx, angle_deg.to_radians());
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;
if !self.color_dragging {
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;
if !self.color_dragging {
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.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.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].engine.selection_clear();
set_document_selection_mask(&mut self.documents[self.active_doc], None);
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));
}
Message::AiAssistantTabChanged(tab) => {
self.ai_assistant_tab = tab;
}
Message::ComfyUiCheckStatus => {
// Placeholder: check ComfyUI server health.
// Full implementation would use hcie-vision to query the ComfyUI API.
self.comfy_state.status = ai_chat::ComfyStatus::Running;
}
Message::ComfyUiUrlChanged(url) => {
self.comfy_state.url = url;
}
// ── 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::PasteAsNewLayer => {
// Check internal clipboard first
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
{
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
let centered = selection::clipboard::centered_layer_pixels(
&transform.pixels,
transform.width,
transform.height,
canvas_w,
canvas_h,
);
let id = doc.engine.add_layer("Pasted Layer");
doc.engine.set_layer_pixels(id, centered);
doc.engine.set_active_layer(id);
doc.modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// Fall back to system clipboard
return Task::perform(
async { crate::io::clipboard::paste_image_from_clipboard() },
Message::ImagePastedAsNewLayer,
);
}
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();
if w > canvas_w || h > canvas_h {
// Stash and ask before pasting — paste would crop on transform commit.
doc.pending_paste = Some((pixels, w, h));
self.active_dialog = ActiveDialog::ExpandCanvas {
paste_w: w,
paste_h: h,
canvas_w,
canvas_h,
};
} else {
// Fits — create the floating transform now (existing behavior).
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);
}
Message::ImagePastedAsNewLayer(Ok(Some((pixels, w, h)))) => {
log::info!("Paste as new layer: {}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();
if w > canvas_w || h > canvas_h {
// Stash and ask before pasting — need canvas expansion.
doc.pending_paste = Some((pixels, w, h));
doc.pending_paste_as_new_layer = true;
self.active_dialog = ActiveDialog::ExpandCanvas {
paste_w: w,
paste_h: h,
canvas_w,
canvas_h,
};
} else {
// Fits — create new layer with centered pixels directly.
let centered = selection::clipboard::centered_layer_pixels(
&pixels, w, h, canvas_w, canvas_h,
);
let id = doc.engine.add_layer("Pasted Layer");
doc.engine.set_layer_pixels(id, centered);
doc.engine.set_active_layer(id);
doc.modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::ImagePastedAsNewLayer(Ok(None)) => {
log::info!("No image in clipboard for paste-as-new-layer");
}
Message::ImagePastedAsNewLayer(Err(e)) => {
log::error!("Failed to paste as new layer: {}", 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();
match self.pending_file_operation.take() {
Some(PendingFileOperation::Open) => {
match self.open_path_in_new_tab(path, true) {
Ok(_) => {
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}");
self.save_error_state =
Some(crate::dialogs::save_error::SaveErrorState {
operation: "Save As".to_string(),
path: path.clone(),
error: error.clone(),
suggestion: Self::suggest_alternative_path(&path),
});
self.active_dialog = ActiveDialog::SaveError;
}
}
}
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<String> = 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: crate::canvas::texture_update::SharedCompositePixels::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,
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_texture: None,
selection_model_dirty: false,
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_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,
vector_drag_preview: None,
rotation_snap_active: false,
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
pending_thumbnail_layers: std::collections::HashSet::new(),
thumb_gen: 0,
});
self.active_doc = self.documents.len() - 1;
}
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;
self.documents[idx].full_upload.replace(true);
self.documents[idx].selection_mask_dirty.set(true);
}
}
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.documents[idx].full_upload.replace(true);
self.documents[idx].selection_mask_dirty.set(true);
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)) => match self.open_path_in_new_tab(path, true) {
Ok(_) => return Task::perform(async {}, |_| Message::CompositeRefresh),
Err(error) => log::error!("Failed to open file: {error}"),
},
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}");
self.save_error_state =
Some(crate::dialogs::save_error::SaveErrorState {
operation: "Save".to_string(),
path: path.clone(),
error: error.clone(),
suggestion: Self::suggest_alternative_path(&path),
});
self.active_dialog = ActiveDialog::SaveError;
}
}
} else {
return self.update(Message::SaveFileAs);
}
}
// ── Menu ─────────────────────────────────────
Message::MenuOpen(idx) => {
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() {
match self.open_path_in_new_tab(path, true) {
Ok(_) => return Task::perform(async {}, |_| Message::CompositeRefresh),
Err(error) => log::error!("Failed to open recent file: {error}"),
}
} 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 => {
return self.update(Message::SelectAll);
}
MenuCommand::Deselect => {
return self.update(Message::Deselect);
}
MenuCommand::InvertSelection => {
self.documents[self.active_doc].engine.selection_invert();
self.documents[self.active_doc].selection_model_dirty = true;
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 = 10.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::ViewerToggle => {
return Task::perform(async {}, |_| Message::ViewerToggle)
}
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::ImportFile => {
return Task::perform(
async {
let path = rfd::AsyncFileDialog::new()
.add_filter(
"Images",
&[
"png", "jpg", "jpeg", "webp", "bmp", "tiff", "psd",
"kra",
],
)
.pick_file()
.await
.map(|h| h.path().to_path_buf());
path
},
|path| {
if let Some(p) = path {
Message::FileOpened(Ok(p))
} else {
Message::NoOp
}
},
)
}
MenuCommand::ImportSvg => {
return Task::perform(
async {
let path = rfd::AsyncFileDialog::new()
.add_filter("SVG", &["svg"])
.pick_file()
.await
.map(|h| h.path().to_path_buf());
path
},
|path| {
if let Some(p) = path {
Message::FileOpened(Ok(p))
} else {
Message::NoOp
}
},
)
}
MenuCommand::ImportBrushes => {
return Task::perform(
async {
let path = rfd::AsyncFileDialog::new()
.add_filter("Brushes", &["abr", "kpp"])
.pick_file()
.await
.map(|h| h.path().to_path_buf());
path
},
|path| {
if let Some(_p) = path {
// Trigger brush import
Message::BrushImportAbr
} else {
Message::NoOp
}
},
);
}
MenuCommand::Export => return Task::perform(async {}, |_| Message::SaveFileAs),
MenuCommand::PasteSpecial => {
return Task::perform(async {}, |_| Message::PasteImage)
}
MenuCommand::PasteAsNewLayer => {
return Task::perform(async {}, |_| Message::PasteAsNewLayer)
}
MenuCommand::Fill => {
let doc = &mut self.documents[self.active_doc];
let fg = self.fg_color;
if let Some(mut pixels) = doc.engine.get_active_layer_pixels() {
let w = doc.engine.canvas_width() as usize;
let h = doc.engine.canvas_height() as usize;
for y in 0..h {
for x in 0..w {
let idx = (y * w + x) * 4;
if idx + 3 < pixels.len() {
pixels[idx] = fg[0];
pixels[idx + 1] = fg[1];
pixels[idx + 2] = fg[2];
pixels[idx + 3] = fg[3];
}
}
}
doc.engine.set_active_layer_pixels(pixels);
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::Stroke => {
log::info!("Stroke requested — not yet implemented");
}
MenuCommand::FreeTransform => {
// Start free transform from current selection mask
let doc = &mut self.documents[self.active_doc];
let mask = doc.engine.get_selection_mask().map(|m| m.to_vec());
if let Some(mask_data) = mask {
let w = doc.engine.canvas_width();
let h = doc.engine.canvas_height();
let tr = crate::selection::SelectionTransform::from_engine(
&mut doc.engine,
&mask_data,
w,
h,
);
// Clear selected pixels from the active layer (cut)
if let Some(mut layer_pixels) = doc.engine.get_active_layer_pixels() {
let cw = w as usize;
let ch = h as usize;
for y in 0..ch {
for x in 0..cw {
let midx = y * cw + x;
if midx < mask_data.len() && mask_data[midx] > 0 {
let pidx = midx * 4;
if pidx + 3 < layer_pixels.len() {
layer_pixels[pidx..pidx + 3].fill(0);
}
}
}
}
doc.engine.set_active_layer_pixels(layer_pixels);
}
doc.engine.selection_clear();
set_document_selection_mask(doc, Some(mask_data));
doc.selection_transform = Some(tr);
self.refresh_composite_if_needed();
}
}
MenuCommand::Preferences => {
log::info!("Preferences requested — not yet implemented in ICED");
}
MenuCommand::Rotate90CW => {
let doc = &mut self.documents[self.active_doc];
let active = doc.engine.active_layer_id();
doc.engine.rotate_layer(active, 90.0);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::Rotate90CCW => {
let doc = &mut self.documents[self.active_doc];
let active = doc.engine.active_layer_id();
doc.engine.rotate_layer(active, -90.0);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::Rotate180 => {
let doc = &mut self.documents[self.active_doc];
let active = doc.engine.active_layer_id();
doc.engine.rotate_layer(active, 180.0);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::FlipHorizontal => {
let doc = &mut self.documents[self.active_doc];
let active = doc.engine.active_layer_id();
doc.engine.flip_layer_horizontal(active);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::FlipVertical => {
let doc = &mut self.documents[self.active_doc];
let active = doc.engine.active_layer_id();
doc.engine.flip_layer_vertical(active);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::ImageCrop => {
if self.documents[self.active_doc].selection_rect.is_some() {
return Task::perform(async {}, |_| Message::ToolSelected(Tool::Crop));
}
}
MenuCommand::InvertNegative => {
let doc = &mut self.documents[self.active_doc];
doc.engine.apply_filter("invert", serde_json::json!({}));
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::AlignLayer(axis) => {
let align_axis = match axis.as_str() {
"left" => hcie_engine_api::AlignAxis::Left,
"right" => hcie_engine_api::AlignAxis::Right,
"top" => hcie_engine_api::AlignAxis::Top,
"bottom" => hcie_engine_api::AlignAxis::Bottom,
"center" | "hcenter" => hcie_engine_api::AlignAxis::HCenter,
"middle" | "vcenter" => hcie_engine_api::AlignAxis::VCenter,
_ => hcie_engine_api::AlignAxis::Left,
};
let doc = &mut self.documents[self.active_doc];
doc.engine.align_active_layer(align_axis);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::ErodeBorder => {
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Erode");
}
MenuCommand::FadeBorder => {
self.dialog_selection_value = 8.0;
self.active_dialog = ActiveDialog::SelectionOp("Fade");
}
MenuCommand::FilterDistort(filter_id) => {
let ft = filter_id_to_type(&filter_id);
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
}
MenuCommand::FilterNoise(filter_id) => {
let ft = filter_id_to_type(&filter_id);
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
}
MenuCommand::FilterPixelate(filter_id) => {
let ft = filter_id_to_type(&filter_id);
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
}
MenuCommand::FilterRender(filter_id) => {
let ft = filter_id_to_type(&filter_id);
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
}
MenuCommand::FilterRestore(filter_id) => {
let ft = filter_id_to_type(&filter_id);
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
}
MenuCommand::FilterStylize(filter_id) => {
let ft = filter_id_to_type(&filter_id);
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
}
MenuCommand::FilterTexture(filter_id) => {
// Textures have no FilterType variant — apply directly with
// hardcoded default params (mirrors EGUI menus.rs:561-655).
let params = default_texture_params(&filter_id);
let doc = &mut self.documents[self.active_doc];
if !doc.engine.active_layer_is_editable() {
log::warn!("texture apply blocked for locked/group/mask layer");
return Task::none();
}
doc.engine.apply_filter(&filter_id, params);
doc.modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::ResetPan => {
let doc = &mut self.documents[self.active_doc];
doc.pan_offset = iced::Vector::ZERO;
}
MenuCommand::OpenDocuments => {
// Toggle a document list or focus the active doc
if !self.documents.is_empty() {
self.active_doc = 0;
}
}
MenuCommand::ResetLayout => {
self.dock = self.settings.panel_layout.restore_dock();
}
MenuCommand::SelectTool(tool) => {
return Task::perform(async move {}, move |_| Message::ToolSelected(tool))
}
MenuCommand::About => {
self.active_dialog = ActiveDialog::About;
}
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::TitleSearchChanged(text) => {
self.title_search = text;
}
Message::PanelListToggle => {
self.show_panel_list = !self.show_panel_list;
}
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,
svg_editor: 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 last-used directory, system Pictures, or home on first open
let start_dir = self
.settings
.viewer_last_dir
.as_ref()
.map(std::path::PathBuf::from)
.filter(|p| p.exists())
.unwrap_or_else(crate::viewer::default_start_dir);
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.settings.viewer_last_dir = Some(path.to_string_lossy().to_string());
let _ = self.settings.save();
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.
match self.open_path_in_new_tab(path.clone(), true) {
Ok(_) => {
self.viewer_active = false;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => {
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::ViewerToggleNode(path) => {
self.viewer_state.toggle_node(&path);
}
Message::ViewerThemeToggle => {
let current = self.theme_state.preset();
let new_preset = match current {
crate::theme::ThemePreset::Photopea
| crate::theme::ThemePreset::Photoshop
| crate::theme::ThemePreset::ProDark
| crate::theme::ThemePreset::Amoled => crate::theme::ThemePreset::ProLight,
crate::theme::ThemePreset::PhotoshopLight
| crate::theme::ThemePreset::ProLight => crate::theme::ThemePreset::Photopea,
};
self.theme_state.set_preset(new_preset);
self.settings.theme_preset = new_preset;
let _ = self.settings.save();
}
// ── SVG Editor ──────────────────────────────────
Message::SvgEditorOpen {
layer_id,
shape_idx,
} => {
let doc = &self.documents[self.active_doc];
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(hcie_engine_api::VectorShape::SvgShape { svg, .. }) =
shapes.get(shape_idx)
{
match crate::panels::svg_editor::parse_editable(svg) {
Ok(editable) => {
self.svg_editor_state =
Some(crate::panels::svg_editor::SvgEditorState {
layer_id,
shape_idx,
original_svg: svg.clone(),
editable,
selected_poly: 0,
selected_node: None,
zoom: 1.0,
pan: iced::Vector::ZERO,
x_input: String::new(),
y_input: String::new(),
snap_to_grid: false,
grid_size: 0.05,
grid_input: "0.05".to_string(),
});
self.active_dialog = ActiveDialog::SvgEditor;
}
Err(e) => {
log::warn!("SVG Editor open failed: {}", e);
}
}
}
}
}
Message::SvgEditorSelectPoly(idx) => {
if let Some(state) = &mut self.svg_editor_state {
if idx < state.editable.polygons.len() {
state.selected_poly = idx;
state.selected_node = None;
state.refresh_coordinate_inputs();
}
}
}
Message::SvgEditorSelectNode { polygon, node } => {
if let Some(state) = &mut self.svg_editor_state {
state.select_node(polygon, node);
}
}
Message::SvgEditorClearSelection => {
if let Some(state) = &mut self.svg_editor_state {
state.selected_node = None;
state.refresh_coordinate_inputs();
}
}
Message::SvgEditorMoveNode {
polygon,
node,
x,
y,
} => {
if let Some(state) = &mut self.svg_editor_state {
let x = state.snapped(x);
let y = state.snapped(y);
state.editable.move_node(polygon, node, x, y);
state.select_node(polygon, node);
}
self.sync_svg_editor_preview();
}
Message::SvgEditorCanvasReleased => {}
Message::SvgEditorCanvasAddNode { polygon, edge } => {
if let Some(state) = &mut self.svg_editor_state {
state.editable.add_node(polygon, edge);
state.select_node(polygon, edge.saturating_add(1));
}
self.sync_svg_editor_preview();
}
Message::SvgEditorXChanged(input) => {
if let Some(state) = &mut self.svg_editor_state {
state.x_input = input;
if let Some(node_idx) = state.selected_node {
if let (Ok(x), Some(node)) = (
state.x_input.parse::<f32>(),
state
.editable
.polygons
.get(state.selected_poly)
.and_then(|poly| poly.get(node_idx))
.copied(),
) {
let x = state.snapped(x);
state
.editable
.move_node(state.selected_poly, node_idx, x, node.y);
}
}
}
self.sync_svg_editor_preview();
}
Message::SvgEditorYChanged(input) => {
if let Some(state) = &mut self.svg_editor_state {
state.y_input = input;
if let Some(node_idx) = state.selected_node {
if let (Ok(y), Some(node)) = (
state.y_input.parse::<f32>(),
state
.editable
.polygons
.get(state.selected_poly)
.and_then(|poly| poly.get(node_idx))
.copied(),
) {
let y = state.snapped(y);
state
.editable
.move_node(state.selected_poly, node_idx, node.x, y);
}
}
}
self.sync_svg_editor_preview();
}
Message::SvgEditorPan { dx, dy } => {
if let Some(state) = &mut self.svg_editor_state {
state.pan.x += dx;
state.pan.y += dy;
}
}
Message::SvgEditorZoom(delta) => {
if let Some(state) = &mut self.svg_editor_state {
state.zoom = (state.zoom * delta).clamp(0.25, 8.0);
}
}
Message::SvgEditorFit => {
if let Some(state) = &mut self.svg_editor_state {
state.zoom = 1.0;
state.pan = iced::Vector::ZERO;
}
}
Message::SvgEditorSnapChanged(enabled) => {
if let Some(state) = &mut self.svg_editor_state {
state.snap_to_grid = enabled;
}
}
Message::SvgEditorGridChanged(input) => {
if let Some(state) = &mut self.svg_editor_state {
state.grid_input = input;
if let Ok(value) = state.grid_input.parse::<f32>() {
if value.is_finite() && value > f32::EPSILON {
state.grid_size = value;
}
}
}
}
Message::SvgEditorAddNode => {
if let Some(state) = &mut self.svg_editor_state {
let poly = state.selected_poly;
let ni = state.selected_node.unwrap_or(0);
state.editable.add_node(poly, ni);
state.select_node(poly, ni + 1);
}
self.sync_svg_editor_preview();
}
Message::SvgEditorRemoveNode => {
if let Some(state) = &mut self.svg_editor_state {
if let Some(ni) = state.selected_node {
let poly = state.selected_poly;
state.editable.remove_node(poly, ni);
state.selected_node = None;
state.refresh_coordinate_inputs();
}
}
self.sync_svg_editor_preview();
}
Message::SvgEditorSave => {
let new_svg = self.svg_editor_state.as_ref().map(|s| s.editable.to_svg());
if let Some(svg) = new_svg {
let layer_id = self
.svg_editor_state
.as_ref()
.map(|s| s.layer_id)
.unwrap_or(0);
let shape_idx = self
.svg_editor_state
.as_ref()
.map(|s| s.shape_idx)
.unwrap_or(0);
let doc = &mut self.documents[self.active_doc];
doc.engine.set_vector_shape_svg(layer_id, shape_idx, &svg);
doc.modified = true;
self.svg_editor_state = None;
self.active_dialog = ActiveDialog::None;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::SvgEditorCancel => {
if let Some(state) = self.svg_editor_state.as_ref() {
let layer_id = state.layer_id;
let shape_idx = state.shape_idx;
let original_svg = state.original_svg.clone();
self.documents[self.active_doc].engine.set_vector_shape_svg(
layer_id,
shape_idx,
&original_svg,
);
self.refresh_composite_if_needed();
}
self.svg_editor_state = None;
self.active_dialog = ActiveDialog::None;
}
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(false, self.active_menu.is_some()) {
Some(OverlayEscapeTarget::Subtools) => unreachable!(),
Some(OverlayEscapeTarget::Menu) => {
self.active_menu = None;
return Task::none();
}
None => {}
}
if self.viewer_active {
return self.update(Message::ViewerToggle);
} else if matches!(self.active_dialog, ActiveDialog::SvgEditor) {
return self.update(Message::SvgEditorCancel);
} else if 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 {
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
self.colors_dirty = false;
}
// 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 is_light = colors.is_light;
let viewer_panel = crate::viewer::view(&self.viewer_state, colors, is_light);
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,
&self.title_search,
self.show_panel_list,
&self.runtime_version,
);
// Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge
let toolbox = crate::sidebar::view(
&self.tool_state,
&self.fg_color,
&self.bg_color,
colors,
self.open_subtool_slot,
&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),
ActiveDialog::SaveError => {
if let Some(state) = &self.save_error_state {
dialogs::save_error::view(state, colors)
} else {
text("").into()
}
}
ActiveDialog::ExpandCanvas {
paste_w,
paste_h,
canvas_w,
canvas_h,
} => dialogs::expand_canvas::view(*paste_w, *paste_h, *canvas_w, *canvas_h, colors),
ActiveDialog::SvgEditor => {
if self.svg_editor_state.is_some() {
// SVG editor overlay rendered separately below
text("").into()
} else {
text("").into()
}
}
};
// 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();
let has_svg_editor = self.svg_editor_state.is_some()
&& matches!(self.active_dialog, ActiveDialog::SvgEditor);
if has_dialog
|| has_menu
|| has_dock_profile_dialog
|| has_context_menu
|| has_color_picker
|| has_floating
|| has_svg_editor
{
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_svg_editor {
if let Some(state) = &self.svg_editor_state {
stack = stack.push(crate::panels::svg_editor::view(state, colors));
}
}
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,
self.settings.window.height,
) {
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();
if !self.tool_state.is_drawing {
crate::canvas::perf::record_idle_view(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();
if !self.tool_state.is_drawing {
crate::canvas::perf::record_idle_view(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<Message> {
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 }
if status == iced::event::Status::Ignored =>
{
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,
}
});
// Captured canvas events normally stay local to the retained overlay.
// During a real global drag, forward captured movement so dock/dialog
// interactions continue across the canvas without rebuilding the app
// for every idle canvas movement.
let captured_drag_pointer = if self.layer_style_dragging
|| self.dock.header_drag.is_some()
|| self.dock.drag.is_some()
|| self.dock.floating_drag.is_some()
{
iced::event::listen_with(|event, status, _id| match event {
iced::Event::Mouse(iced::mouse::Event::CursorMoved { position })
if status == iced::event::Status::Captured =>
{
Some(Message::DockPointerMoved(position.x, position.y))
}
_ => None,
})
} else {
iced::Subscription::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()
};
// Raw pointer messages update the brush engine as quickly as input arrives,
// while this bounded timer controls expensive compositing and GPU staging.
let stroke_frames = if should_schedule_canvas_frames(
self.tool_state.is_drawing,
self.tool_state.active_tool,
) {
iced::time::every(std::time::Duration::from_millis(16))
.map(|_| Message::CanvasFrameTick)
} else {
iced::Subscription::none()
};
iced::Subscription::batch(vec![
keyboard,
mouse,
captured_drag_pointer,
marching_ants,
stroke_frames,
])
}
}
/// 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<OverlayEscapeTarget> {
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)
}
}
/// Determines whether the bounded canvas frame timer should run.
///
/// **Arguments:** `is_drawing` is the pointer-drag state and `tool` is the active editor tool.
/// **Returns:** `true` only for an active raster paint stroke. **Logic & Workflow:** Selection,
/// vector, transform, and idle states continue to use their event-driven previews.
/// **Side Effects / Dependencies:** None.
fn should_schedule_canvas_frames(is_drawing: bool, tool: Tool) -> bool {
is_drawing && matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray)
}
#[cfg(test)]
mod cycle_one_ux_tests {
use super::{
active_index_after_document_close, consume_selection_sync_request,
document_close_disposition, overlay_escape_target, should_schedule_canvas_frames,
union_regions, DocumentCloseDisposition, HcieIcedApp, Message, OverlayEscapeTarget,
};
use hcie_engine_api::Tool;
/// 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);
}
/// Ensures an unchanged selection cannot trigger work on every drawing refresh.
#[test]
fn selection_sync_request_is_consumed_once() {
let mut requested = true;
assert!(consume_selection_sync_request(&mut requested));
for _ in 0..120 {
assert!(!consume_selection_sync_request(&mut requested));
}
requested = true;
assert!(consume_selection_sync_request(&mut requested));
}
/// Protects the multi-event dirty accumulator from dropping an earlier update.
#[test]
fn dirty_region_union_retains_all_updates_before_view() {
let first = union_regions(None, [10, 20, 30, 40]);
let union = union_regions(Some(first), [25, 5, 50, 35]);
assert_eq!(union, [10, 5, 50, 40]);
}
/// Confirms the frame timer is bounded to active raster strokes.
#[test]
fn canvas_frame_timer_runs_only_for_active_paint_strokes() {
for tool in [Tool::Pen, Tool::Brush, Tool::Eraser, Tool::Spray] {
assert!(should_schedule_canvas_frames(true, tool));
assert!(!should_schedule_canvas_frames(false, tool));
}
assert!(!should_schedule_canvas_frames(true, Tool::Select));
assert!(!should_schedule_canvas_frames(true, Tool::VectorSelect));
}
/// Confirms a newly created document becomes the selected tab.
///
/// **Purpose:** Guards against a tab-management regression where the new
/// document existed but later operations still targeted the previous tab.
/// **Logic & Workflow:** Creates application state, dispatches the normal
/// `NewDocument` message, and checks both count and active index.
/// **Arguments & Returns:** None. **Side Effects / Dependencies:** Allocates
/// two in-memory engine documents and reads ordinary application settings.
#[test]
fn new_document_becomes_the_active_tab() {
let (mut app, _) = HcieIcedApp::new(None, None);
let previous_count = app.documents.len();
let _ = app.update(Message::NewDocument(32, 24));
assert_eq!(app.documents.len(), previous_count + 1);
assert_eq!(app.active_doc, app.documents.len() - 1);
assert_eq!(app.documents[app.active_doc].engine.canvas_width(), 32);
assert_eq!(app.documents[app.active_doc].engine.canvas_height(), 24);
}
/// Confirms opening another file appends a tab without mutating the first.
///
/// **Purpose:** Reproduces the reported second-document overwrite regression.
/// **Logic & Workflow:** Writes a tiny PNG, opens it through the shared tab
/// helper, and verifies the initial document identity and new active tab.
/// **Arguments & Returns:** None. **Side Effects / Dependencies:** Creates and
/// removes one temporary image file; recent-file persistence is disabled.
#[test]
fn opening_a_second_file_preserves_the_existing_tab() {
let (mut app, _) = HcieIcedApp::new(None, None);
let original_name = app.documents[0].name.clone();
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("hcie-open-tab-{}-{unique}.png", std::process::id()));
image::save_buffer(
&path,
&[255, 0, 0, 255, 0, 255, 0, 255],
2,
1,
image::ColorType::Rgba8,
)
.unwrap();
let opened_index = app.open_path_in_new_tab(path.clone(), false).unwrap();
assert_eq!(app.documents.len(), 2);
assert_eq!(opened_index, 1);
assert_eq!(app.active_doc, 1);
assert_eq!(app.documents[0].name, original_name);
assert!(app.documents[0].source_path.is_none());
assert_eq!(
app.documents[1].source_path.as_deref(),
Some(path.as_path())
);
assert_eq!(app.documents[1].engine.canvas_width(), 2);
assert_eq!(app.documents[1].engine.canvas_height(), 1);
let _ = std::fs::remove_file(path);
}
// ── Recent color proximity dedup ─────────────────────
/// Confirms an identical color IS proximate (diff=0 ≤ threshold).
/// In practice this means an exact duplicate click is deduped, which is
/// acceptable because the color is already at (or near) the front of the list.
#[test]
fn proximate_identical_color_is_skipped() {
let last = [100, 150, 200, 255];
let color = [100, 150, 200, 255];
// Identical → not proximate because the function returns true for
// threshold ≤2 per channel; identical (diff=0) is within threshold
// so `is_proximate_to_last` returns true.
assert!(super::is_proximate_to_last(&last, &color));
}
/// Confirms a slider-step color (+1 in one channel) IS proximate and skipped.
#[test]
fn proximate_small_slider_step_skipped() {
let last = [100, 150, 200, 255];
let slider_step = [101, 150, 200, 255];
assert!(super::is_proximate_to_last(&last, &slider_step));
}
/// Confirms a +3 change in all channels is NOT proximate (exceeds threshold).
#[test]
fn large_change_not_proximate() {
let last = [100, 150, 200, 255];
let far = [105, 155, 205, 255];
assert!(!super::is_proximate_to_last(&last, &far));
}
/// Confirms a single-channel +3 jump exceeds the threshold.
#[test]
fn three_unit_change_exceeds_threshold() {
let last = [100, 150, 200, 255];
let three_away = [103, 150, 200, 255];
assert!(!super::is_proximate_to_last(&last, &three_away));
}
/// Confirms small mixed changes are still within the proximity window.
#[test]
fn mixed_small_changes_are_proximate() {
let last = [100, 150, 200, 255];
let mixed_small = [101, 151, 201, 255];
assert!(super::is_proximate_to_last(&last, &mixed_small));
}
}
/// 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<u64> {
let ux = x.round().clamp(0.0, u32::MAX as f32) as u32;
let uy = y.round().clamp(0.0, u32::MAX as f32) as u32;
let infos = engine.layer_infos();
// layer_infos returns bottom-to-top; iterate in reverse for topmost first.
for info in infos.iter().rev() {
if info.layer_type == hcie_engine_api::LayerType::Text && info.visible {
if let Some(px) = engine.get_pixel(info.id, ux, uy) {
if px[3] > 10 {
return Some(info.id);
}
}
}
}
None
}