Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/app.rs
T
phantom a2846d8b3a A GOOD MILESTONE : Refactor title bar and toolbar typography, improve tooltip styles, and enhance selection transform functionality
- Updated typography constants for menu bar and tooltips to improve readability and consistency.
- Adjusted button padding and widths in the title bar for better layout.
- Replaced tooltip implementation with a new balloon tooltip style for improved visual feedback.
- Enhanced selection transform logic to support rotation and resizing, ensuring accurate hit testing and bounds calculations.
- Added tests for selection clearing and transformed bounds to ensure functionality and prevent regressions.
- Introduced a new module for managing selection transform dirty bounds during interactive previews.
2026-07-18 20:31:31 +03:00

8185 lines
378 KiB
Rust

//! 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 transform_dirty;
use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT};
use crate::dialogs;
use crate::dock::manager::DockDragState;
use crate::dock::state::{DockState, PaneType};
use crate::i18n;
use crate::io::tablet::TabletState;
use crate::panels;
use crate::selection;
use crate::selection::{CropState, SelectionTransform, TransformHandle};
use crate::theme::ThemeState;
use self::transform_dirty::preview_bounds;
use hcie_engine_api::{
BlendMode, BrushStyle, BrushTip, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN,
};
use iced::widget::{column, container, text};
use iced::{Element, Length, Task, Theme, Vector};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Returns the path to the HCIE config directory (~/.config/hcie-iced/).
/// Creates the directory if it doesn't exist.
fn config_dir() -> PathBuf {
let dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("hcie-iced");
let _ = std::fs::create_dir_all(&dir);
dir
}
/// Returns the path to the recent files JSON config.
fn recent_files_path() -> PathBuf {
config_dir().join("recent_files.json")
}
/// Load recent files from disk.
fn load_recent_files() -> Vec<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).
fn load_colors() -> PersistedColors {
match std::fs::read_to_string(colors_path()) {
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
Err(_) => PersistedColors::default(),
}
}
/// Save colors to disk.
fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) {
let data = PersistedColors {
fg: Some(fg),
bg: Some(bg),
recent: recent.to_vec(),
};
if let Ok(json) = serde_json::to_string_pretty(&data) {
let _ = std::fs::write(colors_path(), json);
}
}
/// Active dialog type.
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum ActiveDialog {
None,
NewImage,
BrightnessContrast,
HueSaturation,
CloseConfirm,
SelectionOp(&'static str),
LayerStyleDialog,
ImageSize,
CanvasSize,
About,
}
/// Top-level application state.
pub struct HcieIcedApp {
/// All open documents.
pub documents: Vec<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,
/// Whether the sub-tools popup is open for the active slot.
pub sub_tools_open: bool,
/// Per-slot last-used sub-tool index (egui's `slot_last_used`). The sidebar
/// shows the last-used tool's icon/label for each slot rather than always
/// the primary tool.
pub slot_last_used: Vec<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,
/// 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,
/// 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,
/// Context menu screen position (x, y). None means closed.
pub canvas_context_menu: Option<(f32, f32)>,
/// Dirty flag set whenever fg/bg/recent colors change, so colors can be
/// persisted to disk (egui persists these under the `hcie_colors` key).
pub colors_dirty: bool,
/// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration).
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
/// Last drag position during vector shape editing (canvas coordinates).
pub vector_drag_last: Option<(f32, f32)>,
/// Timestamp of the last expensive vector rerasterization during a drag.
pub vector_drag_last_render: Option<std::time::Instant>,
/// 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,
}
/// 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,
/// Composite RGBA pixel data shared with the shader pipeline via Arc.
/// The shader's `prepare()` reads this and uploads only the dirty region
/// via `queue.write_texture()`. Updated in `refresh_composite_if_needed()`.
pub composite_pixels: Arc<Vec<u8>>,
/// Mutable composite buffer for partial-copy from engine.
/// After updating, this is wrapped in a new Arc for the shader.
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>,
/// Selection mask (alpha channel) for the current selection
pub selection_mask: Option<Vec<u8>>,
/// Whether the selection mask 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>,
/// Cache for extracted selection edges (marching ants).
pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache,
/// Extracted selection edges for overlay rendering.
pub marching_ants_edges: Option<Arc<Vec<(u32, u32, u32, u32)>>>,
/// Horizontal runs of selected pixels used to fill irregular masks exactly.
pub selection_fill_spans: Option<Arc<Vec<(u32, u32, u32)>>>,
/// 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,
}
/// In-progress text layer being authored with the Text tool.
///
/// Holds the editable content plus font/size/color/alignment/orientation so the
/// properties panel and canvas overlay can render a live preview before the
/// text is committed to the engine as a Text layer.
#[derive(Debug)]
pub struct TextDraft {
pub content: String,
pub font: String,
pub size: f32,
pub color: [u8; 4],
pub x: f32,
pub y: f32,
pub angle: f32,
pub alignment: hcie_engine_api::TextAlignment,
pub orientation: hcie_engine_api::TextOrientation,
/// Stateful multiline editor content kept in sync with `content`.
pub editor: iced::widget::text_editor::Content,
}
impl Default for TextDraft {
fn default() -> Self {
Self {
content: String::new(),
font: "Arial".to_string(),
size: 24.0,
color: [0, 0, 0, 255],
x: 100.0,
y: 100.0,
angle: 0.0,
alignment: hcie_engine_api::TextAlignment::Left,
orientation: hcie_engine_api::TextOrientation::Horizontal,
editor: iced::widget::text_editor::Content::new(),
}
}
}
/// Drawing tool state.
pub struct ToolState {
/// Currently selected tool.
pub active_tool: Tool,
/// Whether the user is currently drawing (mouse button held).
pub is_drawing: bool,
/// Canvas-space coordinates of the last pointer event during a stroke.
pub last_stroke_pos: Option<(f32, f32)>,
/// Accumulated distance for the current stroke (for pressure interpolation).
pub brush_accumulated_dist: f32,
/// Current brush size.
pub brush_size: f32,
/// Current brush opacity.
pub brush_opacity: f32,
/// Current brush hardness.
pub brush_hardness: f32,
/// Current brush style.
pub brush_style: BrushStyle,
/// Current brush spacing.
pub brush_spacing: f32,
/// Timestamp of the last composite refresh for throttling during strokes.
pub last_composite_refresh: std::time::Instant,
/// Timestamp of the last update() call for frame timing.
pub last_update_instant: std::time::Instant,
/// Imported brush presets from ABR/KPP files.
pub imported_brushes: Vec<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,
last_composite_refresh: std::time::Instant::now(),
last_update_instant: std::time::Instant::now(),
imported_brushes: Vec::new(),
is_resizing_brush: false,
brush_resize_start_size: 20.0,
brush_resize_start_pos: None,
last_click_time: std::time::Instant::now(),
last_click_pos: None,
space_pan: false,
}
}
}
/// Messages the application handles.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Message {
// ── Tool selection ──────────────────────────────────
ToolSelected(Tool),
ToolSlotClicked(usize, Tool),
/// Opens or closes a slot's subtool chooser without changing the active tool.
SubtoolsToggle(usize),
/// Dismisses a transient toolbox or menu popup after an uncaptured pointer press.
OverlayOutsideClick,
// ── Color ───────────────────────────────────────────
FgColorChanged([u8; 4]),
BgColorChanged([u8; 4]),
SwapColors,
ResetColors,
ColorTabChanged(usize),
// ── Canvas interaction ──────────────────────────────
CanvasPointerPressed {
x: f32,
y: f32,
},
CanvasPointerMoved {
x: f32,
y: f32,
},
CanvasPointerReleased,
CanvasPointerRightClicked {
x: f32,
y: f32,
screen_x: f32,
screen_y: f32,
},
CanvasContextMenuClose,
CanvasPanZoom {
zoom: f32,
pan_offset: Vector,
},
CanvasZoomSet(f32),
CanvasZoomRelative(f32),
FitToArea,
CanvasSize((f32, f32)),
CanvasCursorPos {
x: u32,
y: u32,
},
ModifiersChanged(iced::keyboard::Modifiers),
SpacePanChanged(bool),
// ── Engine operations ───────────────────────────────
CompositeRefresh,
Undo,
Redo,
// ── Layer operations ────────────────────────────────
LayerSelect(u64),
LayerAdd,
LayerDelete(u64),
LayerToggleVisibility(u64),
LayerToggleLock(u64),
LayerSetOpacity(u64, f32),
LayerMergeDown,
LayerFlatten,
LayerMoveUp(u64),
LayerMoveDown(u64),
LayerSetBlendMode(u64, BlendMode),
LayerSetFillOpacity(u64, f32),
LayerAddGroup,
LayerToggleCollapse(u64),
// ── History ─────────────────────────────────────────
HistoryJumpTo(i32),
// ── Text tool ───────────────────────────────────────
TextSizeChanged(f32),
TextColorChanged([u8; 4]),
TextAlignChanged(String),
TextAccept,
TextCancel,
// ── Layer styles ────────────────────────────────────
LayerStyleToggle(u32),
LayerStyleSelect(usize),
LayerStyleApply,
LayerStyleCancel,
LayerStyleUpdateParam(usize, String, f32),
LayerStyleUpdateBlendMode(usize, String),
LayerStyleUpdateColor(usize, [u8; 4]),
LayerStyleUpdateString(usize, String, String),
LayerStyleUpdateShadowColor(usize, [u8; 4]),
LayerStyleUpdateShadowBlend(usize, String),
LayerStyleUpdateShadowOpacity(usize, f32),
ShowStyleColorPicker(usize),
ShowStyleShadowColorPicker(usize),
HideStyleColorPicker,
OpenColorPicker(crate::color_picker::ColorPickerTarget, [u8; 4]),
CloseColorPicker,
ColorPickerTabChanged(usize),
ColorPickerHexChanged(String),
PopupColorChanged([u8; 4]),
OpenLayerStyleDialog,
LayerStyleDialogDragStart(f32, f32),
LayerStyleDialogDragMove(f32, f32),
LayerStyleDialogDragEnd,
LayerStyleAdd,
// ── Brushes ─────────────────────────────────────────
BrushStyleSelected(BrushStyle),
BrushSizeChanged(f32),
BrushSizeRelative(f32),
BrushOpacityChanged(f32),
BrushHardnessChanged(f32),
BrushFlowChanged(f32),
BrushSpacingChanged(f32),
BrushImportAbr,
BrushImportAbrFile(Result<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),
TextFontChanged(String),
TextAngleChanged(f32),
TextOrientationChanged(String),
TextContentChanged(String),
TextEditorAction(iced::widget::text_editor::Action),
TextCommit,
ToolApplyToNewLayerToggled(bool),
// ── Filters ─────────────────────────────────────────
FilterSelect(FilterType),
FilterApply,
FilterReset,
FilterParamChanged(String, serde_json::Value),
FilterPreviewToggle(bool),
FilterPreviewApply,
FilterPreviewRestore,
// ── Dialogs ─────────────────────────────────────────
DialogOpen(ActiveDialog),
DialogClose,
DialogCloseSave,
DialogCloseDiscard,
// ── New Image Dialog ────────────────────────────────
DialogNewImageName(String),
DialogNewImageWidth(u32),
DialogNewImageHeight(u32),
DialogNewImageTransparent(bool),
DialogNewImagePreset(u32, u32),
DialogNewImageCreate,
// ── Adjustments Dialog ──────────────────────────────
AdjBrightness(f32),
AdjContrast(f32),
AdjHue(f32),
AdjSaturation(f32),
AdjLightness(f32),
AdjApply,
// ── Selection Dialog ────────────────────────────────
SelectionOpValue(f32),
SelectionOpApply,
// ── Image Size Dialog ──────────────────────────────
DialogImageSizeWidth(u32),
DialogImageSizeHeight(u32),
DialogImageSizeConstrain(bool),
DialogImageSizeApply,
// ── Canvas Size Dialog ─────────────────────────────
DialogCanvasSizeWidth(u32),
DialogCanvasSizeHeight(u32),
DialogCanvasSizeAnchor(usize, usize),
DialogCanvasSizeApply,
// ── Selection tools ─────────────────────────────────
SelectionDragStart(f32, f32),
SelectionDragMove(f32, f32),
SelectionDragEnd,
SelectAll,
Deselect,
SelectInverse,
SelectionModeChanged(selection::SelectionMode),
SelectionSave,
SelectionLoad,
QuickMaskToggle,
ClearPixels,
// ── Selection transform ─────────────────────────────
TransformDragStart(f32, f32),
TransformDragMove(f32, f32),
TransformDragEnd,
TransformApply,
TransformCancel,
// ── Vector tools ────────────────────────────────────
VectorDrawStart(f32, f32),
VectorDrawMove(f32, f32),
VectorDrawEnd,
// ── Vector select / edit ────────────────────────────
VectorSelectClick {
x: f32,
y: f32,
},
VectorSelectDragStart {
x: f32,
y: f32,
},
VectorSelectDragMove {
x: f32,
y: f32,
},
VectorSelectDragEnd,
VectorShapeDelete,
VectorShapeSelect(Option<usize>),
VectorShapeApply,
VectorShapeCancel,
VectorShapeFlipH,
VectorShapeFlipV,
// ── Geometry panel controls ──────────────────────────
GeometryBoundsX1(f32),
GeometryBoundsY1(f32),
GeometryBoundsX2(f32),
GeometryBoundsY2(f32),
GeometryFillToggled(bool),
GeometryFillColorChanged([u8; 4]),
GeometryStrokeColorChanged([u8; 4]),
GeometryOpacityChanged(f32),
GeometryHardnessChanged(f32),
GeometryBoolShapeA(Option<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),
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,
CopyText(String),
PasteText,
ClipboardResult(Result<Option<String>, String>),
ImageCopied(Result<(), String>),
ImagePasted(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,
/// Executes a semantic command supplied by an enabled menu leaf.
MenuCommand(crate::panels::menus::MenuCommand),
// ── Window ───────────────────────────────────────────
WindowDrag,
WindowMinimize,
WindowMaximize,
WindowClose,
WindowId(iced::window::Id),
ScreenshotCapture,
ScreenshotReady(iced::window::Screenshot),
ThemeChanged(crate::theme::ThemePreset),
// ── Dock Profiles ───────────────────────────────────
DockProfileSave(String),
DockProfileLoad(String),
DockProfileRename(String, String),
DockProfileDelete(String),
DockProfileRenameStart(String),
DockProfileRenameConfirm,
DockProfileRenameCancel,
DockProfileNewNameChanged(String),
DockProfileRenameChanged(String),
DockProfileDialogShow,
DockProfileDialogHide,
// ── Misc ────────────────────────────────────────────
NoOp,
// ── Tablet / touch feed ─────────────────────────────
TabletTouch {
x: f32,
y: f32,
pressed: bool,
},
// ── Contextual key actions (resolved in update with self) ──
EscapePressed,
EnterPressed,
// ── Image Viewer ────────────────────────────────────
ViewerToggle,
ViewerSetMode(crate::viewer::ViewerMode),
ViewerNavigate(std::path::PathBuf),
ViewerSelectFile(std::path::PathBuf),
ViewerOpenFile(std::path::PathBuf),
ViewerRefresh,
ViewerFullscreen(bool),
ViewerPrev,
ViewerNext,
ViewerEnter,
// ── Toolbox expand/collapse ─────────────────────────
SidebarToggleExpanded,
}
/// Convert a FilterType enum to the engine's string filter ID.
fn filter_type_to_id(filter: FilterType) -> &'static str {
match filter {
FilterType::BoxBlur => "box_blur",
FilterType::GaussianBlur => "gaussian_blur",
FilterType::MotionBlur => "motion_blur",
FilterType::UnsharpMask => "sharpen",
FilterType::Mosaic => "mosaic",
FilterType::Pinch => "pinch",
FilterType::Twirl => "twirl",
FilterType::OilPaint => "oil_paint",
FilterType::Crystallize => "crystallize",
FilterType::Levels => "levels",
FilterType::Vibrance => "vibrance",
FilterType::Exposure => "exposure",
FilterType::Posterize => "posterize",
FilterType::Threshold => "threshold",
FilterType::ChannelMixer => "channel_mixer",
FilterType::BlackAndWhite => "black_and_white",
FilterType::SelectiveColor => "selective_color",
FilterType::GammaCorrection => "gamma_correction",
FilterType::ExtractChannel => "extract_channel",
FilterType::GradientMap => "gradient_map",
FilterType::GaussianBlurGamma => "gaussian_blur_gamma",
FilterType::BoxBlurGamma => "box_blur_gamma",
FilterType::MedianFilter => "median_filter",
FilterType::Dehaze => "dehaze",
FilterType::NoisePattern => "noise_pattern",
}
}
/// Build a BrushTip from the current tool state.
///
/// Converts the tool state's brush parameters into an engine BrushTip
/// that can be passed to `engine.set_brush_tip()` before starting a stroke.
fn build_brush_tip(state: &ToolState) -> BrushTip {
BrushTip {
style: state.brush_style,
size: state.brush_size,
opacity: state.brush_opacity,
hardness: state.brush_hardness,
spacing: state.brush_spacing,
..BrushTip::default()
}
}
/// Convert a display label back to a PaneType for dock profile loading.
fn pane_type_from_label(label: &str) -> Option<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),
_ => None,
}
}
/// Helper to combine two bounding boxes of dirty regions.
fn union_regions(r1: Option<[u32; 4]>, r2: [u32; 4]) -> [u32; 4] {
match r1 {
Some(r) => [
r[0].min(r2[0]),
r[1].min(r2[1]),
r[2].max(r2[2]),
r[3].max(r2[3]),
],
None => r2,
}
}
/// Whether a tool is a vector-shape tool that uses the shared drag flow.
fn is_vector_shape_tool(tool: Tool) -> bool {
matches!(
tool,
Tool::VectorRect
| Tool::VectorCircle
| Tool::VectorLine
| Tool::VectorArrow
| Tool::VectorStar
| Tool::VectorPolygon
| Tool::VectorRhombus
| Tool::VectorCylinder
| Tool::VectorHeart
| Tool::VectorBubble
| Tool::VectorGear
| Tool::VectorCross
| Tool::VectorCrescent
| Tool::VectorBolt
| Tool::VectorArrow4
)
}
/// Clears mask-covered alpha values without creating an engine history entry.
///
/// **Purpose:** Starts a move transaction while deferring history creation until Apply, avoiding
/// the engine's standalone `Clear Selection` snapshot used by the Delete command.
///
/// **Logic & Workflow:** Visits one mask byte per canvas pixel and clears only the corresponding
/// layer alpha byte when coverage is non-zero, matching `Engine::clear_selection_pixels`.
///
/// **Arguments:** `pixels` is active-layer RGBA data and `mask` is one-byte selection coverage.
/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates `pixels`; performs no allocation,
/// engine call, or history operation.
fn clear_masked_pixels_without_history(pixels: &mut [u8], mask: &[u8]) {
for (index, coverage) in mask.iter().copied().enumerate() {
let alpha_index = index * 4 + 3;
if coverage > 0 && alpha_index < pixels.len() {
pixels[alpha_index] = 0;
}
}
}
/// Restores a rectangular region from a stable transform preview baseline.
///
/// **Purpose:** Removes the prior floating preview without restoring the full canvas per pointer
/// event. **Logic & Workflow:** Copies each row in the clipped exclusive rectangle from `source`
/// to `destination`. **Arguments:** Buffers are full-canvas RGBA data, `canvas_width` is their row
/// stride, and `bounds` is `[x0, y0, x1, y1]`. **Returns:** Nothing.
/// **Side Effects / Dependencies:** Mutates only `destination` rows inside `bounds`; no allocation.
fn restore_preview_region(
destination: &mut [u8],
source: &[u8],
canvas_width: u32,
bounds: [u32; 4],
) {
let row_bytes = (bounds[2] - bounds[0]) as usize * 4;
for y in bounds[1]..bounds[3] {
let start = (y as usize * canvas_width as usize + bounds[0] as usize) * 4;
let end = start + row_bytes;
if end <= destination.len() && end <= source.len() {
destination[start..end].copy_from_slice(&source[start..end]);
}
}
}
/// Applies a floating transform and records exactly one caller-labelled layer snapshot.
///
/// **Purpose:** Commits move and paste transforms atomically using the same inverse-affine sampler
/// as preview. **Logic & Workflow:** Reads the current non-preview layer, composites the transform,
/// installs final pixels, and pushes the supplied original baseline only when pixels changed.
///
/// **Arguments:** `engine` is the API boundary, `tr` is floating geometry, canvas dimensions define
/// raster clipping, `before_pixels` is the transaction baseline, and `history_name` labels undo.
/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates the active layer and may append one
/// engine history snapshot; emits DEBUG timing and transaction-boundary logs.
fn apply_transform_to_layer(
engine: &mut hcie_engine_api::Engine,
tr: &selection::SelectionTransform,
canvas_w: u32,
canvas_h: u32,
before_pixels: Vec<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 updates the shader Arc in place when uniquely owned. A full-buffer Arc clone is used
/// only when Iced still owns the prior frame's immutable Arc.
///
/// **Arguments:** `doc` contains transform/composite state and `previous_bounds` identifies stale
/// preview pixels. **Returns:** Nothing. **Side Effects / Dependencies:** Mutates preview buffers,
/// the dirty-region RefCell, and render generation; logs bounds and elapsed time at DEBUG level.
fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32; 4]>) {
let started = std::time::Instant::now();
let canvas_width = doc.engine.canvas_width();
let canvas_height = doc.engine.canvas_height();
if let (Some(base), Some(bounds)) = (doc.transform_preview_base.as_ref(), previous_bounds) {
restore_preview_region(&mut doc.composite_raw, base, canvas_width, bounds);
}
let current_bounds = doc.selection_transform.as_ref().and_then(|transform| {
selection::transform::composite_transform(
&mut doc.composite_raw,
canvas_width,
canvas_height,
transform,
)
});
let changed_bounds = match (previous_bounds, current_bounds) {
(Some(previous), Some(current)) => Some(union_regions(Some(previous), current)),
(Some(bounds), None) | (None, Some(bounds)) => Some(bounds),
(None, None) => None,
};
if let Some(bounds) = changed_bounds {
let pending = doc.dirty_region.replace(None);
let upload_bounds = union_regions(pending, bounds);
doc.dirty_region.replace(Some(upload_bounds));
if let Some(shader_pixels) = Arc::get_mut(&mut doc.composite_pixels) {
restore_preview_region(shader_pixels, &doc.composite_raw, canvas_width, upload_bounds);
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
doc.render_generation = doc.render_generation.wrapping_add(1);
log::debug!(
"[TransformPreview] previous={previous_bounds:?}, current={current_bounds:?}, upload={upload_bounds:?}, elapsed_us={}",
started.elapsed().as_micros()
);
}
}
impl HcieIcedApp {
/// Combines the engine's newly generated selection with the previous mask.
///
/// **Arguments:** `previous` is the selection mask captured before an engine selection call.
/// **Returns:** Nothing. **Side Effects / Dependencies:** Replaces the engine selection mask
/// through `hcie-engine-api` when add/subtract/intersect mode is active.
fn combine_current_selection(&mut self, previous: Option<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 {
return;
};
let combined =
selection::state::combine_masks(previous.as_deref(), &new_mask, doc.selection_mode);
doc.engine.set_selection_mask(combined);
}
/// Create the initial application state.
///
/// If `load_path` is provided, the file will be opened after initialization.
/// Returns a Task to capture the window ID on startup.
pub fn new(
load_path: Option<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: Arc::new(composite_raw.clone()),
composite_raw,
name: "Untitled".to_string(),
source_path: None,
modified: false,
zoom: 1.0,
pan_offset: Vector::ZERO,
cached_layers,
cached_history,
history_current,
selection_rect: None,
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
selection_transform: None,
transform_drag_handle: TransformHandle::None,
transform_drag_start: None,
transform_original: None,
transform_source: None,
transform_layer_baseline: None,
transform_selection_baseline: None,
transform_preview_base: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
selection_history_marker: 0,
crop_state: CropState::default(),
lasso_points: Vec::new(),
polygon_points: Vec::new(),
gradient_drag: None,
vision_rect: None,
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selection_fill_spans: None,
selection_mode: selection::SelectionMode::Replace,
saved_selection_mask: None,
quick_mask: false,
selected_vector_shape: None,
vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
};
// Load saved settings
let settings = crate::settings::AppSettings::load();
let mut app = Self {
documents: vec![doc],
show_welcome: false,
active_doc: 0,
tool_state: ToolState::default(),
fg_color: [220, 50, 50, 255],
bg_color: [255, 255, 255, 255],
last_cursor_pos: None,
canvas_cursor_pos: None,
active_dialog: ActiveDialog::None,
active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush).unwrap_or(0),
sub_tools_open: false,
sidebar_expanded: settings.panel_layout.sidebar_expanded,
slot_last_used: vec![0; 20],
dialog_input: String::new(),
dialog_input2: String::new(),
show_performance: false,
ui_scale: 1.0,
modifiers: iced::keyboard::Modifiers::default(),
canvas_context_menu: None,
settings: settings.clone(),
window_id: None,
screenshot_request,
dialog_new_name: "Untitled".to_string(),
dialog_new_width: 800,
dialog_new_height: 600,
dialog_new_transparent: true,
dialog_brightness: 0.0,
dialog_contrast: 0.0,
dialog_hue: 0.0,
dialog_saturation: 0.0,
dialog_lightness: 0.0,
dialog_selection_value: 5.0,
dialog_image_size_width: 800,
dialog_image_size_height: 600,
dialog_image_size_constrain: true,
dialog_image_size_original_ratio: 800.0 / 600.0,
dialog_canvas_size_width: 800,
dialog_canvas_size_height: 600,
dialog_canvas_size_anchor: (1, 1),
selected_filter: None,
filter_params: serde_json::json!({}),
filter_preview_active: false,
preview_baseline: None,
layer_style_baseline: None,
pending_file_operation: None,
pending_document_close: None,
dock: settings.panel_layout.restore_dock(),
tablet_state: Arc::new(Mutex::new(TabletState::new())),
initialized: false,
theme_state: ThemeState::new(settings.theme_preset),
active_menu: None,
recent_files: load_recent_files(),
recent_colors: Vec::new(),
color_picker_target: None,
popup_color: [0, 0, 0, 255],
popup_color_hex: "000000FF".to_string(),
popup_color_tab: 1,
selected_style: 0,
show_style_color_picker: false,
style_color_picker_idx: 0,
style_color_hsl: (0.0, 0.0, 0.0),
style_color_is_shadow: false,
color_tab: 0,
layer_style_offset: (0.0, 0.0),
layer_style_dragging: false,
layer_style_drag_start: None,
marching_ants_offset: 0.0,
ai_chat_state: AiChatState::default(),
ai_chat_abort: None,
script_text: crate::script::parser::DEFAULT_SCRIPT.to_string(),
script_content: iced::widget::text_editor::Content::with_text(
crate::script::parser::DEFAULT_SCRIPT,
),
script_error: None,
script_error_line: None,
script_is_running: false,
ai_script_prompt: String::new(),
ai_script_output: String::new(),
ai_script_error: None,
ai_script_notice: None,
ai_script_is_running: false,
ai_script_provider: "ollama".to_string(),
ai_script_model: "qwen2.5:7b".to_string(),
ai_script_url: "http://localhost:11434".to_string(),
viewer_active: false,
viewer_state: crate::viewer::ViewerState::default(),
active_dock_profile: None,
dock_profile_new_name: String::new(),
dock_profile_rename_idx: None,
dock_profile_rename_buf: String::new(),
show_dock_profile_dialog: false,
language: i18n::Language::default(),
colors_dirty: false,
vector_shapes_snapshot: None,
vector_drag_last: None,
vector_drag_last_render: None,
vector_drag_session: None,
bool_shape_a: None,
bool_shape_b: None,
};
if let Some(panel) = app
.screenshot_request
.as_ref()
.and_then(|request| request.panel.as_deref())
.and_then(crate::dock::state::PaneType::from_label)
{
app.dock.reopen_pane(panel);
}
if let Some(panel) = app
.screenshot_request
.as_ref()
.and_then(|request| request.floating_panel.as_deref())
.and_then(crate::dock::state::PaneType::from_label)
{
app.dock.reopen_pane(panel);
if let Some(pane) = app.dock.pane_for_type(panel) {
app.dock.float_pane(pane, (settings.window.width, settings.window.height));
}
}
if let Some(panel) = app
.screenshot_request
.as_ref()
.and_then(|request| request.auto_hide_panel.as_deref())
.and_then(crate::dock::state::PaneType::from_label)
{
app.dock.reopen_pane(panel);
if let Some(pane) = app.dock.pane_for_type(panel) {
app.dock.auto_hide_pane(
pane,
Some(crate::dock::manager::DockEdge::Right),
);
}
}
if app
.screenshot_request
.as_ref()
.is_some_and(|request| request.welcome)
{
app.show_welcome = true;
}
// Apply saved settings to tool state
settings.apply_to_tool_state(&mut app.tool_state);
// Restore persisted colors (fg/bg/recent) from the colors file, mirroring
// egui's `hcie_colors` JSON persistence in app/mod.rs:380-386.
let colors = load_colors();
if colors.fg.is_some() {
app.fg_color = colors.fg.unwrap();
}
if colors.bg.is_some() {
app.bg_color = colors.bg.unwrap();
}
if !colors.recent.is_empty() {
app.recent_colors = colors.recent;
}
let fg = app.fg_color;
app.active_document_mut().engine.set_color(fg);
app.settings = settings;
// If a file path was provided, open it (route multi-layer formats to dedicated importers)
if let Some(path) = load_path {
let path_str = path.to_string_lossy().to_string();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => app.documents[0].engine.import_psd(&path_str),
"kra" => app.documents[0].engine.import_kra(&path_str),
"hcie" => app.documents[0].engine.load_native(&path_str),
_ => app.documents[0].engine.open_image(&path_str),
};
match result {
Ok(()) => {
app.documents[0].engine.pre_tile_all_layers();
app.documents[0].name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
app.documents[0].source_path = Some(path.clone());
app.add_recent_file(&path);
let composite = app.documents[0].engine.get_composite_pixels();
let size = composite.len();
log::info!(
"[startup] composite pixels: {} bytes ({}x{})",
size,
app.documents[0].engine.canvas_width(),
app.documents[0].engine.canvas_height()
);
app.documents[0].composite_raw = composite;
app.documents[0].composite_pixels =
std::sync::Arc::new(app.documents[0].composite_raw.clone());
app.documents[0].full_upload.replace(true);
app.documents[0].render_generation =
app.documents[0].render_generation.wrapping_add(1);
app.documents[0].cached_layers = app.documents[0].engine.layer_infos();
let history_len = app.documents[0].engine.history_len();
app.documents[0].cached_history = (0..history_len)
.filter_map(|i| {
app.documents[0]
.engine
.history_description(i)
.map(|d| (i, d))
})
.collect();
app.documents[0].history_current = app.documents[0].engine.history_current();
}
Err(e) => {
log::error!("Failed to open file on startup: {}", e);
}
}
}
app.initialized = true;
let init_task = iced::window::get_oldest().map(|opt_id| {
if let Some(id) = opt_id {
Message::WindowId(id)
} else {
Message::NoOp
}
});
(app, init_task)
}
/// Return a mutable reference to the active document.
fn active_document_mut(&mut self) -> &mut IcedDocument {
self.documents
.get_mut(self.active_doc)
.expect("no active document")
}
/// Convert pane-relative coordinates to canvas-space coordinates.
///
/// The canvas image is centered in the pane, with `pan_offset` as an
/// additional offset from center. This function accounts for both.
fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
let doc = &self.documents[self.active_doc];
let zoom = doc.zoom;
let (pane_w, pane_h) = doc.pane_size;
let engine_w = doc.engine.canvas_width() as f32;
let engine_h = doc.engine.canvas_height() as f32;
let display_w = engine_w * zoom;
let display_h = engine_h * zoom;
// Canvas image position = center of pane + pan_offset
let canvas_origin_x = (pane_w - display_w) / 2.0 + doc.pan_offset.x;
let canvas_origin_y = (pane_h - display_h) / 2.0 + doc.pan_offset.y;
// Convert from pane-space to canvas-space
let canvas_x = (sx - canvas_origin_x) / zoom;
let canvas_y = (sy - canvas_origin_y) / zoom;
if canvas_x >= 0.0 && canvas_x < engine_w && canvas_y >= 0.0 && canvas_y < engine_h {
Some((canvas_x, canvas_y))
} else {
None
}
}
/// Hit-test which vector edit handle is at the given canvas-space position.
///
/// Checks rotation handle first (circle area), then resize handles (square
/// hit-box), then the move handle (inside bounding box). Returns the
/// matching `VectorEditHandle` variant. Coordinates are in canvas-space.
fn vector_hit_test_handle(&self, cx: f32, cy: f32) -> hcie_engine_api::VectorEditHandle {
use hcie_engine_api::VectorEditHandle as VEH;
let doc = &self.documents[self.active_doc];
let Some(idx) = doc.selected_vector_shape else {
return VEH::None;
};
let layer_id = doc.engine.active_layer_id();
let shapes = match doc.engine.active_vector_shapes() {
Some(s) => s,
None => return VEH::None,
};
let shape = match shapes.get(idx) {
Some(s) => s,
None => return VEH::None,
};
let bounds = shape.normalized_bounds();
let angle = doc.engine.vector_shape_angle(layer_id, idx);
crate::vector_edit::hit_test_handle(bounds, angle, (cx, cy), doc.zoom)
}
/// Add a file to the recent files list (most recent first, max 20) and persist to disk.
fn add_recent_file(&mut self, path: &std::path::Path) {
let path_str = path.to_string_lossy().to_string();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path_str.clone());
// Remove duplicates
self.recent_files.retain(|e| e.path != path_str);
// Insert at front
self.recent_files.insert(
0,
RecentFileEntry {
path: path_str,
name,
},
);
// Cap at 20
self.recent_files.truncate(20);
// Persist to disk
save_recent_files(&self.recent_files);
}
/// Captures the active layer once so repeated previews always start from identical pixels.
fn begin_preview(&mut self) -> bool {
self.restore_preview();
let document_index = self.active_doc;
let layer_id = self.documents[document_index].engine.active_layer_id();
let Some(pixels) = self.documents[document_index]
.engine
.get_layer_pixels(layer_id)
else {
return false;
};
self.preview_baseline = Some(PreviewBaseline {
document_index,
layer_id,
pixels,
});
true
}
/// Restores preview pixels without consuming the immutable baseline.
fn reset_preview_to_baseline(&mut self) -> bool {
let Some(baseline) = self.preview_baseline.as_ref() else {
return false;
};
if baseline.document_index >= self.documents.len() {
return false;
}
self.documents[baseline.document_index]
.engine
.set_layer_pixels(baseline.layer_id, baseline.pixels.clone());
true
}
/// Restores preview pixels and ends the current preview session.
fn restore_preview(&mut self) -> bool {
let restored = self.reset_preview_to_baseline();
self.preview_baseline = None;
restored
}
/// Saves a document using extension-based engine API routing.
fn save_document_to(
&self,
document_index: usize,
path: &std::path::Path,
) -> Result<(), String> {
let path_str = path.to_string_lossy();
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if extension.is_empty() {
return Err("Save As requires a supported file extension".to_string());
}
let engine = &self.documents[document_index].engine;
match extension.as_str() {
"hcie" => engine.save_native(&path_str),
"psd" => engine.export_psd(&path_str),
"kra" => engine.export_kra(&path_str),
"png" | "jpg" | "jpeg" | "webp" | "avif" | "bmp" | "gif" | "tiff" | "tif" | "tga"
| "exr" => engine.save_as(&path_str, &extension),
_ => Err(format!("Unsupported save extension: {extension}")),
}
}
/// Applies successful-save identity updates and records the path as recent.
fn finish_successful_save(&mut self, document_index: usize, path: std::path::PathBuf) {
let name = path
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "Untitled".to_string());
let doc = &mut self.documents[document_index];
doc.source_path = Some(path.clone());
doc.name = name;
doc.modified = false;
self.add_recent_file(&path);
}
/// Continues window-close processing after one document has saved successfully.
fn continue_close_after_save(&mut self) -> Task<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.active_dialog = ActiveDialog::CloseConfirm;
Task::none()
} else {
self.active_dialog = ActiveDialog::None;
self.window_id.map_or_else(Task::none, iced::window::close)
}
}
/// Removes one document tab and keeps the editor shell alive for the final tab.
///
/// Arguments: `document_index` is the tab to remove. Returns: Nothing. Logic & Workflow:
/// indices before the active tab shift it left; closing the active tab selects the tab now at
/// that position or the preceding final tab. Side Effects: Drops the document and edit state.
fn close_document_tab(&mut self, document_index: usize) {
if document_index >= self.documents.len() {
return;
}
if self.documents.len() == 1 {
let _ = self.update(Message::NewDocument(800, 600));
self.documents.remove(document_index);
self.active_doc = 0;
self.active_dialog = ActiveDialog::None;
self.show_welcome = true;
self.pending_document_close = None;
self.vector_shapes_snapshot = None;
self.vector_drag_session = None;
self.vector_drag_last = None;
self.vector_drag_last_render = None;
return;
}
self.documents.remove(document_index);
self.active_doc = active_index_after_document_close(
self.active_doc,
document_index,
self.documents.len(),
);
self.vector_shapes_snapshot = None;
self.vector_drag_session = None;
self.vector_drag_last = None;
}
/// Add a color to the recent colors list (most recent first, max 40).
fn add_recent_color(&mut self, color: [u8; 4]) {
self.recent_colors.retain(|c| *c != color);
self.recent_colors.insert(0, color);
self.recent_colors.truncate(40);
}
/// Refresh the composite buffer using incremental dirty-region compositing.
///
/// Uses `render_composite_region()` which only re-composites dirty tiles.
/// Only the dirty region is copied from the engine's scratch buffer into
/// the local composite buffer, avoiding a full 33 MB copy for small strokes.
fn refresh_composite_if_needed(&mut self) {
let doc = &mut self.documents[self.active_doc];
if doc.engine.is_composite_dirty() {
let (dirty_rect, ptr, len) = doc.engine.render_composite_region();
if len > 0 && !ptr.is_null() {
let mut is_full_copy = false;
// Resize composite_raw if canvas dimensions changed (e.g. after PSD import)
if doc.composite_raw.len() != len {
doc.composite_raw.resize(len, 0);
doc.full_upload.replace(true);
is_full_copy = true;
}
// If the canvas changed size, we MUST force a full upload because the old memory
// structure is no longer valid, and a partial copy would leave zeros in the new buffer.
if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) {
let w = doc.engine.canvas_width() as usize;
let h = doc.engine.canvas_height() as usize;
let [x0, y0, x1, y1] = rect;
let x0 = x0 as usize;
let y0 = y0 as usize;
let x1 = x1 as usize;
let y1 = y1 as usize;
let row_bytes = (x1 - x0) * 4;
if row_bytes > 0 {
unsafe {
let src_ptr = ptr;
let dst_raw_ptr = doc.composite_raw.as_mut_ptr();
let mut pixels_ptr = std::ptr::null_mut();
let mut need_new_arc = false;
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
// Important: resize arc if needed before taking mutable pointer
if pixels.len() != len {
pixels.resize(len, 0);
}
pixels_ptr = pixels.as_mut_ptr();
} else {
need_new_arc = true;
}
for y in y0..y1 {
let offset = (y * w + x0) * 4;
std::ptr::copy_nonoverlapping(
src_ptr.add(offset),
dst_raw_ptr.add(offset),
row_bytes
);
if !pixels_ptr.is_null() {
std::ptr::copy_nonoverlapping(
src_ptr.add(offset),
pixels_ptr.add(offset),
row_bytes
);
}
}
if need_new_arc {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
}
}
let current = doc.dirty_region.replace(None);
doc.dirty_region.replace(Some(union_regions(current, rect)));
} else {
// Full update
unsafe {
std::ptr::copy_nonoverlapping(ptr, doc.composite_raw.as_mut_ptr(), len);
}
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
if pixels.len() != len {
pixels.resize(len, 0);
}
unsafe {
std::ptr::copy_nonoverlapping(ptr, pixels.as_mut_ptr(), len);
}
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
doc.full_upload.replace(true);
doc.dirty_region.replace(None);
}
doc.render_generation = doc.render_generation.wrapping_add(1);
}
doc.engine.clear_dirty_flags();
}
// Update selection edge cache if mask changed
if let Some(mask) = doc.engine.get_selection_mask() {
doc.selection_mask = Some(mask.to_vec());
doc.selection_mask_dirty.set(true);
doc.selection_bounds = selection::state::mask_bounds(
mask,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
);
doc.selection_fill_spans = Some(Arc::new(selection::state::selected_spans(
mask,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
)));
let edges = doc.selection_edge_cache.get(mask).unwrap_or_else(|| {
doc.selection_edge_cache.rebuild(
mask,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
)
});
doc.marching_ants_edges = Some(edges);
} else {
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
doc.selection_bounds = None;
doc.selection_fill_spans = None;
doc.marching_ants_edges = None;
// Clear cache if selection is dropped so next time it won't mistakenly hit
doc.selection_edge_cache = Default::default();
}
// Always refresh cached panel data (cheap operation)
doc.cached_layers = doc.engine.layer_infos();
let history_len = doc.engine.history_len();
doc.cached_history = (0..history_len)
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
.collect();
doc.history_current = doc.engine.history_current();
}
/// Apply brush parameters from tool state to the engine.
///
/// Called before starting a stroke to ensure the engine uses
/// the current brush size, opacity, hardness, and style.
fn apply_brush_params(&mut self) {
let mut tip = build_brush_tip(&self.tool_state);
match self.tool_state.active_tool {
Tool::Pen => {
tip.style = BrushStyle::Round;
tip.size = self.settings.tool_settings.pen_size;
tip.spacing = 0.05;
}
Tool::Spray => {
tip.style = BrushStyle::Spray;
tip.size = self.settings.tool_settings.spray_radius;
tip.spray_particle_size = self.settings.tool_settings.spray_particle_size;
tip.spray_density = self.settings.tool_settings.spray_density;
tip.spacing = 0.5;
}
_ => {}
}
self.documents[self.active_doc].engine.set_brush_tip(tip);
}
// ── Update ──────────────────────────────────────────
/// Handle a message and return an optional command.
pub fn update(&mut self, message: Message) -> Task<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;
}
match message {
Message::ToolSelected(tool) => {
log::info!("Tool selected: {:?}", tool);
self.tool_state.active_tool = tool;
self.sub_tools_open = false;
// Record this tool as the last-used one for its slot, so the
// sidebar shows the last-used tool's icon next time (egui's
// slot_last_used behavior at tool_state.rs:278-286).
if let Some(slot_idx) = crate::sidebar::slot_for_tool(tool) {
self.active_tool_slot = slot_idx;
if let Some(slot) = crate::sidebar::tool_slots().get(slot_idx) {
if let Some(tidx) = slot.tools.iter().position(|&t| t == tool) {
if slot_idx < self.slot_last_used.len() {
self.slot_last_used[slot_idx] = tidx;
}
}
}
}
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::ToolSlotClicked(slot_idx, primary_tool) => {
self.active_tool_slot = slot_idx;
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
self.tool_state.active_tool =
if crate::sidebar::tool_slots().get(slot_idx).is_some() {
tool
} else {
primary_tool
};
self.sub_tools_open = false;
}
Message::SubtoolsToggle(slot_idx) => {
let supports_popup = crate::sidebar::tool_slots()
.get(slot_idx)
.is_some_and(|slot| slot.tools.len() > 1);
if supports_popup {
let opening = !(self.sub_tools_open && self.active_tool_slot == slot_idx);
self.sub_tools_open = opening;
self.active_tool_slot = slot_idx;
if opening && !self.sidebar_expanded {
self.sidebar_expanded = true;
self.settings.panel_layout.sidebar_expanded = true;
self.settings.panel_layout.sidebar_width = 72.0;
let _ = self.settings.save();
}
}
}
Message::OverlayOutsideClick => {
if self.dock.active_auto_hide.take().is_some() {
return Task::none();
}
if self.sub_tools_open {
self.sub_tools_open = false;
} else {
self.active_menu = None;
}
}
Message::FgColorChanged(color) => {
self.fg_color = color;
self.active_document_mut().engine.set_color(color);
self.add_recent_color(color);
self.colors_dirty = true;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
}
Message::BgColorChanged(color) => {
self.bg_color = color;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
}
Message::SwapColors => {
std::mem::swap(&mut self.fg_color, &mut self.bg_color);
let color = self.fg_color;
self.active_document_mut().engine.set_color(color);
self.add_recent_color(color);
self.colors_dirty = true;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
}
Message::ResetColors => {
// Reset fg=black, bg=white (egui: toolbox.rs:351-354).
self.fg_color = [0, 0, 0, 255];
self.bg_color = [255, 255, 255, 255];
let fg = self.fg_color;
self.active_document_mut().engine.set_color(fg);
self.colors_dirty = true;
crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed();
}
Message::ColorTabChanged(tab) => {
self.color_tab = tab;
}
Message::OpenColorPicker(target, color) => {
self.color_picker_target = Some(target);
self.popup_color = color;
self.popup_color_hex = format!(
"{:02X}{:02X}{:02X}{:02X}",
color[0], color[1], color[2], color[3]
);
}
Message::CloseColorPicker => {
self.color_picker_target = None;
}
Message::ColorPickerTabChanged(tab) => {
self.popup_color_tab = tab.min(2);
}
Message::ColorPickerHexChanged(value) => {
self.popup_color_hex = value.clone();
if let Some(color) =
crate::color_picker::parse_hex_color(&value, self.popup_color[3])
{
return self.update(Message::PopupColorChanged(color));
}
}
Message::PopupColorChanged(color) => {
self.popup_color = color;
self.popup_color_hex = format!(
"{:02X}{:02X}{:02X}{:02X}",
color[0], color[1], color[2], color[3]
);
let routed = match self.color_picker_target {
Some(crate::color_picker::ColorPickerTarget::Foreground) => {
Some(Message::FgColorChanged(color))
}
Some(crate::color_picker::ColorPickerTarget::Background) => {
Some(Message::BgColorChanged(color))
}
Some(crate::color_picker::ColorPickerTarget::GeometryFill) => {
Some(Message::GeometryFillColorChanged(color))
}
Some(crate::color_picker::ColorPickerTarget::GeometryStroke) => {
Some(Message::GeometryStrokeColorChanged(color))
}
Some(crate::color_picker::ColorPickerTarget::StyleMain(index))
| Some(crate::color_picker::ColorPickerTarget::StyleHighlight(index)) => {
Some(Message::LayerStyleUpdateColor(index, color))
}
Some(crate::color_picker::ColorPickerTarget::StyleShadow(index)) => {
Some(Message::LayerStyleUpdateShadowColor(index, color))
}
None => None,
};
if let Some(message) = routed {
return self.update(message);
}
}
Message::CanvasPointerPressed { x, y } => {
// Coordinates are already in canvas-space from the canvas widget
let canvas_x = x;
let canvas_y = y;
self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
let tool = self.tool_state.active_tool;
if let Some(transform) =
self.documents[self.active_doc].selection_transform.as_ref()
{
let handle = transform.hit_test(x, y, self.documents[self.active_doc].zoom);
if handle != TransformHandle::None {
self.documents[self.active_doc].transform_drag_handle = handle;
return self.update(Message::TransformDragStart(x, y));
}
}
let now = std::time::Instant::now();
let _is_double_click = if let Some((lx, ly)) = self.tool_state.last_click_pos {
let dx = canvas_x - lx;
let dy = canvas_y - ly;
let dist_sq = dx * dx + dy * dy;
now.duration_since(self.tool_state.last_click_time)
.as_millis()
< 300
&& dist_sq < 25.0
} else {
false
};
self.tool_state.last_click_time = now;
self.tool_state.last_click_pos = Some((canvas_x, canvas_y));
let paint_tool =
matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray);
if paint_tool && (self.modifiers.control() || self.modifiers.logo()) {
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let width = self.documents[self.active_doc].engine.canvas_width();
if cx < width && cy < self.documents[self.active_doc].engine.canvas_height() {
let index = ((cy * width + cx) * 4) as usize;
if index + 3 < self.documents[self.active_doc].composite_raw.len() {
let pixels = &self.documents[self.active_doc].composite_raw;
let color = [
pixels[index],
pixels[index + 1],
pixels[index + 2],
pixels[index + 3],
];
self.fg_color = color;
self.documents[self.active_doc].engine.set_color(color);
}
}
return Task::none();
}
if paint_tool && self.modifiers.shift() {
self.tool_state.is_resizing_brush = true;
self.tool_state.is_drawing = true;
self.tool_state.brush_resize_start_size = self.tool_state.brush_size;
self.tool_state.brush_resize_start_pos = Some((canvas_x, canvas_y));
return Task::none();
}
if let Some(req_type) = tool.allowed_layer_type() {
// Reject raster edits on non-editable layers
if req_type == hcie_engine_api::LayerType::Raster
&& !self.documents[self.active_doc]
.engine
.active_layer_is_editable()
{
// In the future: emit a status message here
return Task::none();
}
// Auto-create layer if current tool requires a different layer type
// NOTE: Vector layer creation is handled by add_vector_shape() in VectorDrawEnd
// to avoid creating duplicate layers. Only create Raster layers here.
if req_type != hcie_engine_api::LayerType::Text
&& req_type != hcie_engine_api::LayerType::Vector
{
if let Some(active) = self.documents[self.active_doc].engine.active_layer()
{
if active.layer_type != req_type {
let id = self.documents[self.active_doc].engine.add_layer("Layer");
self.documents[self.active_doc].engine.set_active_layer(id);
self.refresh_composite_if_needed();
}
}
}
}
match tool {
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
// Apply brush parameters before starting stroke
self.apply_brush_params();
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc].engine.set_tool(tool);
self.documents[self.active_doc]
.engine
.set_eraser(tool == Tool::Eraser);
if tool != Tool::Pen {
self.documents[self.active_doc]
.engine
.begin_stroke(layer_id, canvas_x, canvas_y);
self.documents[self.active_doc]
.engine
.stroke_to(layer_id, canvas_x, canvas_y, 1.0);
}
self.tool_state.is_drawing = true;
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
self.tool_state.brush_accumulated_dist = 0.0;
self.tool_state.last_composite_refresh = std::time::Instant::now();
self.refresh_composite_if_needed();
}
Tool::Eyedropper => {
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
if cx < w && cy < h {
let pixels = &self.documents[self.active_doc].composite_raw;
let idx = ((cy * w + cx) * 4) as usize;
if idx + 3 < pixels.len() {
let color = [
pixels[idx],
pixels[idx + 1],
pixels[idx + 2],
pixels[idx + 3],
];
self.fg_color = color;
self.documents[self.active_doc].engine.set_color(color);
}
}
}
Tool::FloodFill => {
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let color = self.fg_color;
let tol = self.settings.tool_settings.flood_fill_tolerance as u8;
self.documents[self.active_doc]
.engine
.flood_fill(cx, cy, color, tol);
self.refresh_composite_if_needed();
}
Tool::Select => {
// Start selection rectangle drag
let sx = canvas_x;
let sy = canvas_y;
return Task::perform(async move {}, move |_| {
Message::SelectionDragStart(sx, sy)
});
}
Tool::MagicWand => {
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let tol = self.settings.tool_settings.magic_wand_tolerance as u8;
let previous = self.documents[self.active_doc]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
self.documents[self.active_doc]
.engine
.create_selection_magic_wand(cx, cy, tol);
self.combine_current_selection(previous);
self.refresh_composite_if_needed();
}
Tool::SmartSelect => {
// No dedicated AI selection API exposed by the engine;
// fall back to a magic-wand pick at the click point so the
// tool remains usable without a configured SAM backend.
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let tol = self.settings.tool_settings.magic_wand_tolerance as u8;
self.documents[self.active_doc]
.engine
.create_selection_magic_wand(cx, cy, tol);
self.documents[self.active_doc].selection_bounds = {
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
Some((0, 0, w, h))
};
self.refresh_composite_if_needed();
}
Tool::Lasso => {
// Start a freehand lasso path.
self.documents[self.active_doc].lasso_points.clear();
let cx = canvas_x as u32;
let cy = canvas_y as u32;
self.documents[self.active_doc].lasso_points.push((cx, cy));
self.tool_state.is_drawing = true;
}
Tool::PolygonSelect => {
// Polygon lasso: accumulate vertices on each click. Close when
// clicking near the first vertex (≥3 points) or double-clicking.
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let pts = &self.documents[self.active_doc].polygon_points;
let close_dist: i32 = 10;
let near_start = pts.len() >= 3
&& (pts[0].0 as i32 - cx as i32).abs() <= close_dist
&& (pts[0].1 as i32 - cy as i32).abs() <= close_dist;
let near_last = pts
.last()
.map(|l| {
(l.0 as i32 - cx as i32).abs() <= 4
&& (l.1 as i32 - cy as i32).abs() <= 4
})
.unwrap_or(false);
if near_start || near_last {
// Finalize polygon selection.
let pts =
std::mem::take(&mut self.documents[self.active_doc].polygon_points);
if pts.len() >= 3 {
let pts_ref: Vec<(u32, u32)> = pts;
self.documents[self.active_doc]
.engine
.create_selection_polygon(&pts_ref);
self.refresh_composite_if_needed();
}
} else {
self.documents[self.active_doc]
.polygon_points
.push((cx, cy));
}
}
Tool::VisionSelect => {
// Start a bounding-box drag for vision segmentation.
self.tool_state.is_drawing = true;
self.documents[self.active_doc].vision_rect =
Some((canvas_x, canvas_y, canvas_x, canvas_y));
}
Tool::Move => {
// Begin a floating-pixel transform from the active
// selection mask, clearing the source area first.
let doc = &mut self.documents[self.active_doc];
let mask = doc.engine.get_selection_mask().map(|m| m.to_vec());
let w = doc.engine.canvas_width();
let h = doc.engine.canvas_height();
if let Some(mask_data) = mask {
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let in_selection =
(cx < w && cy < h) && mask_data[(cy * w + cx) as usize] > 0;
if in_selection {
let tr = selection::SelectionTransform::from_engine(
&mut doc.engine,
&mask_data,
w,
h,
);
if !tr.is_empty() {
let Some(original_layer) = doc.engine.get_active_layer_pixels()
else {
return Task::none();
};
let mut cut_layer = original_layer.clone();
clear_masked_pixels_without_history(&mut cut_layer, &mask_data);
log::debug!(
"[TransformMove] begin atomic transaction bounds={:?}, selected_pixels={}",
preview_bounds(&tr, w, h),
mask_data.iter().filter(|coverage| **coverage > 0).count()
);
doc.engine.set_active_layer_pixels(cut_layer);
doc.engine.selection_clear();
doc.selection_mask = Some(mask_data.clone());
doc.selection_mask_dirty.set(true);
doc.transform_source = Some(tr.clone());
doc.transform_layer_baseline = Some(original_layer);
doc.transform_selection_baseline = Some(mask_data);
doc.selection_transform = Some(tr);
doc.transform_drag_handle = TransformHandle::Move;
doc.transform_drag_start = Some((canvas_x, canvas_y));
// Force a composite refresh so the cleared source
// area is visible immediately.
doc.engine.mark_composite_dirty();
self.refresh_composite_if_needed();
let doc = &mut self.documents[self.active_doc];
doc.transform_preview_base = Some(doc.composite_raw.clone());
render_transform_preview(doc, None);
}
}
}
}
Tool::Gradient => {
// Start a gradient endpoint drag.
let sx = canvas_x;
let sy = canvas_y;
return Task::perform(async move {}, move |_| {
Message::GradientDragStart(sx, sy)
});
}
Tool::Text => {
// Begin (or continue) an on-canvas text draft at the
// click point. A second click elsewhere moves the draft.
let cx = canvas_x;
let cy = canvas_y;
let doc = &mut self.documents[self.active_doc];
if doc.text_draft.is_none() {
let mut draft = TextDraft::default();
draft.color = self.fg_color;
draft.x = cx;
draft.y = cy;
doc.text_draft = Some(draft);
} else {
doc.text_draft.as_mut().unwrap().x = cx;
doc.text_draft.as_mut().unwrap().y = cy;
}
}
Tool::VectorRect
| Tool::VectorCircle
| Tool::VectorLine
| Tool::VectorArrow
| Tool::VectorStar
| Tool::VectorPolygon
| Tool::VectorRhombus
| Tool::VectorCylinder
| Tool::VectorHeart
| Tool::VectorBubble
| Tool::VectorGear
| Tool::VectorCross
| Tool::VectorCrescent
| Tool::VectorBolt
| Tool::VectorArrow4 => {
// Start vector shape drawing (all shape tools share one drag).
let sx = canvas_x;
let sy = canvas_y;
return Task::perform(async move {}, move |_| {
Message::VectorDrawStart(sx, sy)
});
}
Tool::Crop => {
// Start crop drag
let sx = canvas_x;
let sy = canvas_y;
return Task::perform(async move {}, move |_| {
Message::CropDragStart(sx, sy)
});
}
Tool::VectorSelect => {
// Click to select vector shapes under cursor.
let cx = canvas_x;
let cy = canvas_y;
return Task::perform(async move {}, move |_| Message::VectorSelectClick {
x: cx,
y: cy,
});
}
_ => {}
}
}
Message::CanvasPointerMoved { x, y } => {
// Coordinates are already in canvas-space from the canvas widget
self.canvas_cursor_pos = Some((x as u32, y as u32));
if self.documents[self.active_doc]
.transform_drag_start
.is_some()
{
return self.update(Message::TransformDragMove(x, y));
}
if self.tool_state.is_drawing {
// Selection tools (Lasso, Polygon, Vision, Rect) use is_drawing
// to track drag state but must not enter the brush code path.
let is_selection_tool = matches!(
self.tool_state.active_tool,
Tool::Lasso
| Tool::PolygonSelect
| Tool::Select
| Tool::VisionSelect
| Tool::MagicWand
| Tool::SmartSelect
);
if is_selection_tool {
// Fall through to the dedicated selection drag handlers below.
} else {
let is_ctrl = self.modifiers.control() || self.modifiers.logo();
if is_ctrl {
// Pick color
let cx = x as u32;
let cy = y as u32;
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
if cx < w && cy < h {
let pixels = &self.documents[self.active_doc].composite_raw;
let idx = ((cy * w + cx) * 4) as usize;
if idx + 3 < pixels.len() {
let color = [
pixels[idx],
pixels[idx + 1],
pixels[idx + 2],
pixels[idx + 3],
];
self.fg_color = color;
self.documents[self.active_doc].engine.set_color(color);
}
}
} else if self.tool_state.is_resizing_brush {
if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos {
let dx = x - start_x;
let new_size =
(self.tool_state.brush_resize_start_size + dx).clamp(1.0, 500.0);
self.tool_state.brush_size = new_size;
match self.tool_state.active_tool {
Tool::Pen => self.settings.tool_settings.pen_size = new_size,
Tool::Brush => self.settings.tool_settings.brush_size = new_size,
Tool::Eraser => self.settings.tool_settings.eraser_size = new_size,
Tool::Spray => self.settings.tool_settings.spray_radius = new_size,
_ => {}
}
}
} else {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if self.tool_state.active_tool == Tool::Pen {
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
if !self.documents[self.active_doc].engine.is_stroke_active() {
self.documents[self.active_doc]
.engine
.begin_stroke(layer_id, last_x, last_y);
}
let pressure = self
.tablet_state
.lock()
.map(|state| state.current_pressure())
.unwrap_or(1.0);
self.documents[self.active_doc]
.engine
.draw_pen_segment(layer_id, last_x, last_y, x, y, pressure);
self.tool_state.last_stroke_pos = Some((x, y));
self.refresh_composite_if_needed();
}
return Task::none();
}
let spacing: f32 = match self.tool_state.active_tool {
Tool::Spray => 2.0_f32,
_ => 1.0_f32,
}
.max(1.0);
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
let dx = x - last_x;
let dy = y - last_y;
let dist = (dx * dx + dy * dy).sqrt();
// Use constant pressure for mouse input
// Real tablet pressure comes from tablet_state
let pressure = self
.tablet_state
.lock()
.map(|s| s.current_pressure())
.unwrap_or(1.0);
if dist >= spacing {
self.documents[self.active_doc]
.engine
.stroke_to(layer_id, x, y, pressure);
self.tool_state.last_stroke_pos = Some((x, y));
// Throttle composite refresh to ~60 FPS during strokes.
let now = std::time::Instant::now();
let elapsed =
now.duration_since(self.tool_state.last_composite_refresh);
if elapsed.as_secs_f32() >= 0.016 {
self.tool_state.last_composite_refresh = now;
self.refresh_composite_if_needed();
}
}
} else {
self.documents[self.active_doc]
.engine
.stroke_to(layer_id, x, y, 1.0);
self.tool_state.last_stroke_pos = Some((x, y));
}
}
} // end else (non-selection tool)
}
// Handle selection drag
if self.tool_state.active_tool == Tool::Select {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| {
Message::SelectionDragMove(mx, my)
});
}
// Handle lasso freehand drag (accumulate points).
if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| Message::LassoDragMove(mx, my));
}
// Handle vision-select bounding-box drag.
if self.tool_state.active_tool == Tool::VisionSelect && self.tool_state.is_drawing {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| {
Message::GradientDragMove(mx, my)
});
}
// Handle gradient endpoint drag.
if self.tool_state.active_tool == Tool::Gradient && self.tool_state.is_drawing {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| {
Message::GradientDragMove(mx, my)
});
}
// Handle vector draw drag (all shape tools share one drag).
if is_vector_shape_tool(self.tool_state.active_tool) {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| Message::VectorDrawMove(mx, my));
}
// Handle crop drag
if self.tool_state.active_tool == Tool::Crop {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| Message::CropDragMove(mx, my));
}
// Handle vector select drag
if self.tool_state.active_tool == Tool::VectorSelect
&& self.vector_drag_last.is_some()
{
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| Message::VectorSelectDragMove {
x: mx,
y: my,
});
}
}
Message::CanvasPointerReleased => {
if self.tool_state.is_resizing_brush {
self.tool_state.is_resizing_brush = false;
self.tool_state.is_drawing = false;
self.tool_state.brush_resize_start_pos = None;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
return Task::none();
}
if self.documents[self.active_doc]
.transform_drag_start
.is_some()
{
return self.update(Message::TransformDragEnd);
}
// Lasso release finalizes a freehand selection path.
if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing {
self.tool_state.is_drawing = false;
return Task::perform(async {}, |_| Message::LassoDragEnd);
}
// VisionSelect release finalizes the bbox as a rectangular selection.
if self.tool_state.active_tool == Tool::VisionSelect && self.tool_state.is_drawing {
self.tool_state.is_drawing = false;
let rect = self.documents[self.active_doc].vision_rect;
if let Some((x0, y0, x1, y1)) = rect {
let sx = x0.min(x1) as u32;
let sy = y0.min(y1) as u32;
let ex = x0.max(x1) as u32;
let ey = y0.max(y1) as u32;
if ex > sx && ey > sy {
self.documents[self.active_doc]
.engine
.create_selection_rect(sx, sy, ex, ey);
self.combine_current_selection(None);
self.documents[self.active_doc].selection_bounds =
Some((sx, sy, ex - sx, ey - sy));
self.refresh_composite_if_needed();
}
}
self.documents[self.active_doc].vision_rect = None;
}
// Gradient release applies a linear/radial gradient fill.
if self.tool_state.active_tool == Tool::Gradient && self.tool_state.is_drawing {
self.tool_state.is_drawing = false;
return Task::perform(async {}, |_| Message::GradientDragEnd);
}
if self.tool_state.is_drawing {
// Selection tools use is_drawing to track drag state but never
// start a brush stroke — skip end_stroke / commit_pending_history
// so they don't push a bogus undo entry that empties the canvas.
let is_selection_tool = matches!(
self.tool_state.active_tool,
Tool::Lasso
| Tool::PolygonSelect
| Tool::Select
| Tool::VisionSelect
| Tool::MagicWand
| Tool::SmartSelect
);
if !is_selection_tool {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc].engine.end_stroke(layer_id);
self.documents[self.active_doc]
.engine
.commit_pending_history();
self.tool_state.is_drawing = false;
self.tool_state.last_stroke_pos = None;
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
} // end if !is_selection_tool
}
// End selection drag
if self.tool_state.active_tool == Tool::Select {
return Task::perform(async {}, |_| Message::SelectionDragEnd);
}
// End vector draw (all shape tools)
if is_vector_shape_tool(self.tool_state.active_tool) {
return Task::perform(async {}, |_| Message::VectorDrawEnd);
}
// End crop drag
if self.tool_state.active_tool == Tool::Crop {
return Task::perform(async {}, |_| Message::CropDragEnd);
}
// End vector select drag
if self.tool_state.active_tool == Tool::VectorSelect
&& self.vector_drag_last.is_some()
{
return Task::perform(async {}, |_| Message::VectorSelectDragEnd);
}
}
Message::CanvasPointerRightClicked {
x: _,
y: _,
screen_x,
screen_y,
} => {
// Ignore canvas_x, canvas_y for now, just pop the menu
self.canvas_context_menu = Some((screen_x, screen_y));
}
Message::CanvasContextMenuClose => {
self.canvas_context_menu = None;
}
Message::CanvasPanZoom { zoom, pan_offset } => {
let doc = &mut self.documents[self.active_doc];
doc.zoom = zoom;
doc.pan_offset = pan_offset;
}
Message::CanvasCursorPos { x, y } => {
self.canvas_cursor_pos = Some((x, y));
}
Message::CanvasZoomSet(z) => {
let doc = &mut self.documents[self.active_doc];
doc.zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
// Keep pan_offset stable so the visible content does not jump
// when zooming via keyboard/menu/slider. The canvas view
// centers on pane+pan_offset, so holding pan_offset keeps the
// same logical content anchored.
}
Message::CanvasZoomRelative(factor) => {
let doc = &mut self.documents[self.active_doc];
doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
// Keep pan_offset — zooming relative to center via keyboard.
}
Message::FitToArea => {
let doc = &mut self.documents[self.active_doc];
let fit_x = (doc.pane_size.0 - 24.0) / doc.engine.canvas_width() as f32;
let fit_y = (doc.pane_size.1 - 24.0) / doc.engine.canvas_height() as f32;
doc.zoom = fit_x.min(fit_y).clamp(ZOOM_MIN, ZOOM_MAX);
doc.pan_offset = Vector::ZERO;
}
Message::CanvasSize((w, h)) => {
self.documents[self.active_doc].pane_size = (w, h);
// Feed the canvas pane size to the tablet state so evdev
// absolute-axis position mapping can resolve to canvas coords.
if let Ok(mut ts) = self.tablet_state.lock() {
ts.set_screen_size(w.max(1.0), h.max(1.0));
}
// Keep pan_offset so the canvas stays anchored when the pane
// is resized (e.g. dock splitter dragged).
}
Message::CompositeRefresh | Message::Undo | Message::Redo => {
match message {
Message::Undo => {
let doc = &mut self.documents[self.active_doc];
let current_marker = doc.engine.history_current();
if current_marker == doc.selection_history_marker && !doc.selection_history.is_empty() {
// Undo selection
if let Some(prev_mask) = doc.selection_history.pop() {
if let Some(mask) = prev_mask {
doc.engine.set_selection_mask(mask);
} else {
doc.engine.selection_clear();
}
doc.selection_mask_dirty.set(true);
}
} else {
doc.engine.undo();
doc.selection_history.clear();
}
}
Message::Redo => {
self.documents[self.active_doc].engine.redo();
self.documents[self.active_doc].selection_history.clear();
}
_ => {}
}
self.refresh_composite_if_needed();
}
// ── Layer operations ──────────────────────────
Message::LayerSelect(id) => {
if self.preview_baseline.is_some() {
self.restore_preview();
self.filter_preview_active = false;
self.selected_filter = None;
self.filter_params = serde_json::json!({});
self.refresh_composite_if_needed();
}
let doc = &mut self.documents[self.active_doc];
if doc.engine.active_layer_id() != id {
doc.selected_vector_shape = None;
doc.vector_cycle_index = 0;
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
self.vector_shapes_snapshot = None;
self.vector_drag_last = None;
self.vector_drag_session = None;
self.bool_shape_a = None;
self.bool_shape_b = None;
doc.engine.set_active_layer(id);
}
}
Message::LayerAdd => {
let count = self.documents[self.active_doc].engine.get_layer_count();
self.documents[self.active_doc]
.engine
.add_layer(&format!("Layer {}", count + 1));
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerDelete(id) => {
self.documents[self.active_doc].engine.delete_layer(id);
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleVisibility(id) => {
let visible = self.documents[self.active_doc]
.engine
.get_layer_info(id)
.map(|l| !l.visible)
.unwrap_or(true);
self.documents[self.active_doc]
.engine
.set_layer_visible(id, visible);
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleLock(id) => {
let locked = self.documents[self.active_doc]
.engine
.get_layer_info(id)
.map(|l| !l.locked)
.unwrap_or(false);
self.documents[self.active_doc]
.engine
.set_layer_locked(id, locked);
self.documents[self.active_doc].modified = true;
}
Message::LayerSetOpacity(id, opacity) => {
self.documents[self.active_doc]
.engine
.set_layer_opacity(id, opacity);
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerMergeDown => {
self.documents[self.active_doc].engine.merge_down();
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerFlatten => {
self.documents[self.active_doc].engine.flatten_image();
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerMoveUp(id) => {
let infos = self.documents[self.active_doc].engine.layer_infos();
if let Some(pos) = infos.iter().position(|l| l.id == id) {
if pos > 0 {
let target_idx = pos - 1;
self.documents[self.active_doc]
.engine
.move_layer(id, target_idx);
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
}
Message::LayerMoveDown(id) => {
let infos = self.documents[self.active_doc].engine.layer_infos();
if let Some(pos) = infos.iter().position(|l| l.id == id) {
if pos + 1 < infos.len() {
let target_idx = pos + 1;
self.documents[self.active_doc]
.engine
.move_layer(id, target_idx);
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
}
Message::LayerSetBlendMode(id, mode) => {
self.documents[self.active_doc]
.engine
.set_layer_blend_mode(id, mode);
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerSetFillOpacity(_id, _opacity) => {
// Fill opacity is not yet exposed via engine API; placeholder for future use.
}
Message::LayerAddGroup => {
let count = self.documents[self.active_doc].engine.get_layer_count();
let active_id = self.documents[self.active_doc].engine.active_layer_id();
let active_is_group = self.documents[self.active_doc]
.engine
.get_layer_info(active_id)
.is_some_and(|layer| layer.layer_type == hcie_engine_api::LayerType::Group);
let group_id = self.documents[self.active_doc]
.engine
.add_group(&format!("Group {}", count + 1));
if active_id != 0 && active_id != group_id && !active_is_group {
self.documents[self.active_doc]
.engine
.set_layer_parent(active_id, Some(group_id));
}
self.documents[self.active_doc]
.engine
.set_active_layer(group_id);
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleCollapse(id) => {
self.documents[self.active_doc]
.engine
.toggle_group_collapsed(id);
self.documents[self.active_doc].modified = true;
}
// ── History ───────────────────────────────────
Message::HistoryJumpTo(idx) => {
self.documents[self.active_doc].engine.jump_to_history(idx);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// ── Text tool ─────────────────────────────────
// Text tool handlers (TextSizeChanged, TextColorChanged, TextAlignChanged,
// TextAccept, TextCancel, plus full editor handlers) are defined in the
// brush/tool-options section below.
// ── Layer styles ──────────────────────────────
Message::LayerStyleToggle(index) => {
if let Some(baseline) = self.layer_style_baseline.as_mut() {
baseline.changed = true;
}
// Toggle layer style enabled state.
// BUG FIX: When style doesn't exist, first click creates it ENABLED (not disabled).
// Previous logic used map_or(false, ...) which created disabled styles on first click.
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
if (index as usize) < disc_names.len() {
let disc = disc_names[index as usize];
// Find existing style
let existing = styles.iter().find(|s| {
matches!(
(disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
// Determine new style:
// - Style doesn't exist → create default style with enabled: true
// - Style exists → clone and toggle enabled flag
let new_style = match existing {
None => {
match disc {
"DropShadow" => LayerStyle::DropShadow {
enabled: true,
opacity: 0.75,
angle: 120.0,
distance: 5.0,
spread: 0.0,
size: 5.0,
color: [0, 0, 0, 255],
blend_mode: "Multiply".to_string(),
},
"InnerShadow" => LayerStyle::InnerShadow {
enabled: true,
opacity: 0.75,
angle: 120.0,
distance: 5.0,
spread: 0.0,
size: 5.0,
color: [0, 0, 0, 255],
blend_mode: "Multiply".to_string(),
},
"OuterGlow" => LayerStyle::OuterGlow {
enabled: true,
opacity: 0.75,
spread: 0.0,
size: 5.0,
color: [255, 255, 190, 255],
blend_mode: "Screen".to_string(),
},
"InnerGlow" => LayerStyle::InnerGlow {
enabled: true,
opacity: 0.75,
spread: 0.0,
size: 5.0,
color: [255, 255, 190, 255],
blend_mode: "Screen".to_string(),
},
"BevelEmboss" => LayerStyle::BevelEmboss {
enabled: true,
depth: 1.0,
size: 5.0,
angle: 120.0,
altitude: 30.0,
highlight_opacity: 0.75,
shadow_opacity: 0.75,
direction: "Up".to_string(),
style: "InnerBevel".to_string(),
technique: "Smooth".to_string(),
soften: 0.0,
highlight_blend_mode: "Screen".to_string(),
highlight_color: [255, 255, 255, 255],
shadow_blend_mode: "Multiply".to_string(),
shadow_color: [0, 0, 0, 255],
contour: "Linear".to_string(),
},
"Satin" => LayerStyle::Satin {
enabled: true,
opacity: 0.5,
angle: 120.0,
distance: 11.0,
size: 14.0,
color: [0, 0, 0, 255],
invert: true,
},
"ColorOverlay" => LayerStyle::ColorOverlay {
enabled: true,
opacity: 1.0,
color: [255, 0, 0, 255],
blend_mode: "Normal".to_string(),
},
"GradientOverlay" => LayerStyle::GradientOverlay {
enabled: true,
opacity: 1.0,
blend_mode: "Normal".to_string(),
angle: 90.0,
scale: 1.0,
gradient_type: 0,
},
"PatternOverlay" => LayerStyle::PatternOverlay {
enabled: true,
opacity: 1.0,
blend_mode: "Normal".to_string(),
scale: 1.0,
pattern_name: "".to_string(),
},
"Stroke" => LayerStyle::Stroke {
enabled: true,
size: 3.0,
position: "Outside".to_string(),
opacity: 1.0,
color: [255, 0, 0, 255],
blend_mode: "Normal".to_string(),
},
_ => unreachable!(),
}
}
Some(s) => {
let mut cloned = s.clone();
match &mut cloned {
LayerStyle::DropShadow { enabled, .. }
| LayerStyle::InnerShadow { enabled, .. }
| LayerStyle::OuterGlow { enabled, .. }
| LayerStyle::InnerGlow { enabled, .. }
| LayerStyle::BevelEmboss { enabled, .. }
| LayerStyle::Satin { enabled, .. }
| LayerStyle::ColorOverlay { enabled, .. }
| LayerStyle::GradientOverlay { enabled, .. }
| LayerStyle::PatternOverlay { enabled, .. }
| LayerStyle::Stroke { enabled, .. } => {
*enabled = !*enabled;
}
}
cloned
}
};
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, new_style);
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleSelect(index) => {
self.selected_style = index;
}
Message::LayerStyleApply => {
self.documents[self.active_doc].modified = true;
self.layer_style_baseline = None;
self.active_dialog = ActiveDialog::None;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleCancel => {
if let Some(baseline) = self.layer_style_baseline.take() {
if baseline.changed && baseline.document_index < self.documents.len() {
let doc = &mut self.documents[baseline.document_index];
for index in 0..10 {
doc.engine
.remove_layer_style_by_index(baseline.layer_id, index);
}
for style in baseline.styles {
doc.engine.update_layer_style(baseline.layer_id, style);
}
doc.modified = baseline.was_modified;
self.active_doc = baseline.document_index;
self.refresh_composite_if_needed();
}
}
self.active_dialog = ActiveDialog::None;
}
Message::LayerStyleAdd => {
log::warn!("LayerStyleAdd is unavailable; select one of the ten explicit styles");
}
Message::LayerStyleUpdateParam(index, param, value) => {
if let Some(baseline) = self.layer_style_baseline.as_mut() {
baseline.changed = true;
}
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!(
(disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut new_style = style.clone();
// Update the specific parameter
match (&mut new_style, param.as_str()) {
(LayerStyle::DropShadow { opacity, .. }, "opacity") => {
*opacity = value
}
(LayerStyle::DropShadow { angle, .. }, "angle") => *angle = value,
(LayerStyle::DropShadow { distance, .. }, "distance") => {
*distance = value
}
(LayerStyle::DropShadow { spread, .. }, "spread") => {
*spread = value
}
(LayerStyle::DropShadow { size, .. }, "size") => *size = value,
(LayerStyle::InnerShadow { opacity, .. }, "opacity") => {
*opacity = value
}
(LayerStyle::InnerShadow { angle, .. }, "angle") => *angle = value,
(LayerStyle::InnerShadow { distance, .. }, "distance") => {
*distance = value
}
(LayerStyle::InnerShadow { spread, .. }, "spread") => {
*spread = value
}
(LayerStyle::InnerShadow { size, .. }, "size") => *size = value,
(LayerStyle::OuterGlow { opacity, .. }, "opacity") => {
*opacity = value
}
(LayerStyle::OuterGlow { spread, .. }, "spread") => *spread = value,
(LayerStyle::OuterGlow { size, .. }, "size") => *size = value,
(LayerStyle::InnerGlow { opacity, .. }, "opacity") => {
*opacity = value
}
(LayerStyle::InnerGlow { spread, .. }, "spread") => *spread = value,
(LayerStyle::InnerGlow { size, .. }, "size") => *size = value,
(LayerStyle::BevelEmboss { depth, .. }, "depth") => *depth = value,
(LayerStyle::BevelEmboss { size, .. }, "size") => *size = value,
(LayerStyle::BevelEmboss { angle, .. }, "angle") => *angle = value,
(LayerStyle::BevelEmboss { altitude, .. }, "altitude") => {
*altitude = value
}
(LayerStyle::BevelEmboss { soften, .. }, "soften") => {
*soften = value
}
(
LayerStyle::BevelEmboss {
highlight_opacity,
shadow_opacity,
..
},
"opacity",
) => {
*highlight_opacity = value;
*shadow_opacity = value;
}
(
LayerStyle::BevelEmboss {
highlight_opacity, ..
},
"highlight_opacity",
) => {
*highlight_opacity = value;
}
(
LayerStyle::BevelEmboss { shadow_opacity, .. },
"shadow_opacity",
) => {
*shadow_opacity = value;
}
(LayerStyle::Satin { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::Satin { angle, .. }, "angle") => *angle = value,
(LayerStyle::Satin { distance, .. }, "distance") => {
*distance = value
}
(LayerStyle::Satin { size, .. }, "size") => *size = value,
(LayerStyle::ColorOverlay { opacity, .. }, "opacity") => {
*opacity = value
}
(LayerStyle::GradientOverlay { opacity, .. }, "opacity") => {
*opacity = value
}
(LayerStyle::GradientOverlay { angle, .. }, "angle") => {
*angle = value
}
(LayerStyle::GradientOverlay { scale, .. }, "scale") => {
*scale = value
}
(LayerStyle::PatternOverlay { opacity, .. }, "opacity") => {
*opacity = value
}
(LayerStyle::PatternOverlay { scale, .. }, "scale") => {
*scale = value
}
(LayerStyle::Stroke { size, .. }, "size") => *size = value,
(LayerStyle::Stroke { opacity, .. }, "opacity") => *opacity = value,
_ => {}
}
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, new_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateBlendMode(index, mode) => {
if let Some(baseline) = self.layer_style_baseline.as_mut() {
baseline.changed = true;
}
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!(
(disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut new_style = style.clone();
match &mut new_style {
LayerStyle::DropShadow { blend_mode, .. }
| LayerStyle::InnerShadow { blend_mode, .. }
| LayerStyle::OuterGlow { blend_mode, .. }
| LayerStyle::InnerGlow { blend_mode, .. }
| LayerStyle::ColorOverlay { blend_mode, .. }
| LayerStyle::GradientOverlay { blend_mode, .. }
| LayerStyle::PatternOverlay { blend_mode, .. }
| LayerStyle::Stroke { blend_mode, .. } => *blend_mode = mode,
LayerStyle::BevelEmboss {
highlight_blend_mode,
..
} => *highlight_blend_mode = mode,
_ => {}
}
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, new_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateColor(index, new_color) => {
if let Some(baseline) = self.layer_style_baseline.as_mut() {
baseline.changed = true;
}
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!(
(disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut updated_style = style.clone();
match &mut updated_style {
LayerStyle::DropShadow { color, .. }
| LayerStyle::InnerShadow { color, .. }
| LayerStyle::OuterGlow { color, .. }
| LayerStyle::InnerGlow { color, .. }
| LayerStyle::ColorOverlay { color, .. }
| LayerStyle::Satin { color, .. }
| LayerStyle::Stroke { color, .. } => *color = new_color,
LayerStyle::BevelEmboss {
highlight_color,
shadow_color,
..
} => {
if self.style_color_is_shadow {
*shadow_color = new_color;
} else {
*highlight_color = new_color;
}
}
_ => {}
}
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, updated_style);
}
}
}
// Update the HSL state for the picker
let r = new_color[0] as f32 / 255.0;
let g = new_color[1] as f32 / 255.0;
let b = new_color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min {
0.0
} else if l > 0.5 {
(max - min) / (2.0 - max - min)
} else {
(max - min) / (max + min)
};
let h = if max == min {
0.0
} else if max == r {
((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0
} else if max == g {
((b - r) / (max - min) + 2.0) / 6.0 * 360.0
} else {
((r - g) / (max - min) + 4.0) / 6.0 * 360.0
};
self.style_color_hsl = (h, s, l);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateString(index, param, value) => {
if let Some(baseline) = self.layer_style_baseline.as_mut() {
baseline.changed = true;
}
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!(
(disc, s),
("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
)
});
if let Some(style) = existing {
let mut new_style = style.clone();
match (&mut new_style, param.as_str()) {
(LayerStyle::BevelEmboss { style, .. }, "style") => *style = value,
(LayerStyle::BevelEmboss { technique, .. }, "technique") => {
*technique = value
}
(LayerStyle::BevelEmboss { direction, .. }, "direction") => {
*direction = value
}
(
LayerStyle::BevelEmboss {
highlight_blend_mode,
..
},
"highlight_blend",
) => *highlight_blend_mode = value,
(
LayerStyle::BevelEmboss {
shadow_blend_mode, ..
},
"shadow_blend",
) => *shadow_blend_mode = value,
(LayerStyle::BevelEmboss { contour, .. }, "contour") => {
*contour = value
}
_ => {}
}
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, new_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateShadowColor(index, new_color) => {
if let Some(baseline) = self.layer_style_baseline.as_mut() {
baseline.changed = true;
}
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
});
if let Some(style) = existing {
let mut updated_style = style.clone();
if let LayerStyle::BevelEmboss { shadow_color, .. } = &mut updated_style
{
*shadow_color = new_color;
}
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, updated_style);
}
}
}
let r = new_color[0] as f32 / 255.0;
let g = new_color[1] as f32 / 255.0;
let b = new_color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min {
0.0
} else if l > 0.5 {
(max - min) / (2.0 - max - min)
} else {
(max - min) / (max + min)
};
let h = if max == min {
0.0
} else if max == r {
((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0
} else if max == g {
((b - r) / (max - min) + 2.0) / 6.0 * 360.0
} else {
((r - g) / (max - min) + 4.0) / 6.0 * 360.0
};
self.style_color_hsl = (h, s, l);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateShadowBlend(index, mode) => {
if let Some(baseline) = self.layer_style_baseline.as_mut() {
baseline.changed = true;
}
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
});
if let Some(style) = existing {
let mut updated_style = style.clone();
if let LayerStyle::BevelEmboss {
shadow_blend_mode, ..
} = &mut updated_style
{
*shadow_blend_mode = mode;
}
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, updated_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateShadowOpacity(index, value) => {
if let Some(baseline) = self.layer_style_baseline.as_mut() {
baseline.changed = true;
}
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
});
if let Some(style) = existing {
let mut updated_style = style.clone();
if let LayerStyle::BevelEmboss { shadow_opacity, .. } =
&mut updated_style
{
*shadow_opacity = value;
}
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, updated_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::ShowStyleShadowColorPicker(idx) => {
self.show_style_color_picker = true;
self.style_color_picker_idx = idx;
self.style_color_is_shadow = true;
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 && idx < disc_names.len() {
let disc = disc_names[idx];
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
if let Some(style) = styles.iter().find(|s| {
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
}) {
let color = if let LayerStyle::BevelEmboss { shadow_color, .. } = style {
*shadow_color
} else {
[0, 0, 0, 255]
};
let r = color[0] as f32 / 255.0;
let g = color[1] as f32 / 255.0;
let b = color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min {
0.0
} else if l > 0.5 {
(max - min) / (2.0 - max - min)
} else {
(max - min) / (max + min)
};
let h = if max == min {
0.0
} else if max == r {
((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0
} else if max == g {
((b - r) / (max - min) + 2.0) / 6.0 * 360.0
} else {
((r - g) / (max - min) + 4.0) / 6.0 * 360.0
};
self.style_color_hsl = (h, s, l);
}
}
return Task::none();
}
Message::ShowStyleColorPicker(idx) => {
self.show_style_color_picker = true;
self.style_color_picker_idx = idx;
self.style_color_is_shadow = false;
// Initialize HSL from current color
let disc_names = [
"DropShadow",
"InnerShadow",
"OuterGlow",
"InnerGlow",
"BevelEmboss",
"Satin",
"ColorOverlay",
"GradientOverlay",
"PatternOverlay",
"Stroke",
];
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 && idx < disc_names.len() {
let styles = self.documents[self.active_doc]
.engine
.get_layer_styles(layer_id);
let disc = disc_names[idx];
let color = styles
.iter()
.find(|s| {
matches!(
(disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
})
.map(|s| match s {
LayerStyle::DropShadow { color, .. }
| LayerStyle::InnerShadow { color, .. }
| LayerStyle::OuterGlow { color, .. }
| LayerStyle::InnerGlow { color, .. }
| LayerStyle::ColorOverlay { color, .. }
| LayerStyle::Satin { color, .. }
| LayerStyle::Stroke { color, .. } => *color,
LayerStyle::BevelEmboss {
highlight_color, ..
} => *highlight_color,
_ => [0, 0, 0, 255],
})
.unwrap_or([0, 0, 0, 255]);
let r = color[0] as f32 / 255.0;
let g = color[1] as f32 / 255.0;
let b = color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min {
0.0
} else if l > 0.5 {
(max - min) / (2.0 - max - min)
} else {
(max - min) / (max + min)
};
let h = if max == min {
0.0
} else if max == r {
((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0
} else if max == g {
((b - r) / (max - min) + 2.0) / 6.0 * 360.0
} else {
((r - g) / (max - min) + 4.0) / 6.0 * 360.0
};
self.style_color_hsl = (h, s, l);
}
}
Message::HideStyleColorPicker => {
self.show_style_color_picker = false;
}
Message::OpenLayerStyleDialog => {
let document_index = self.active_doc;
let layer_id = self.documents[document_index].engine.active_layer_id();
self.layer_style_baseline = Some(LayerStyleBaseline {
document_index,
layer_id,
styles: self.documents[document_index]
.engine
.get_layer_styles(layer_id),
was_modified: self.documents[document_index].modified,
changed: false,
});
self.active_dialog = ActiveDialog::LayerStyleDialog;
self.layer_style_offset = (0.0, 0.0);
self.layer_style_dragging = false;
self.layer_style_drag_start = None;
}
Message::LayerStyleDialogDragStart(_x, _y) => {
// Drag started — begin tracking mouse movement.
// The actual offset computation uses delta from the last position.
self.layer_style_dragging = true;
}
Message::LayerStyleDialogDragMove(x, y) | Message::DockPointerMoved(x, y) => {
self.last_cursor_pos = Some((x, y));
let pointer = iced::Point::new(x, y);
if self.dock.drag.is_none() {
if let Some(header) = self.dock.header_drag {
if pointer.distance(header.origin) >= 4.0 {
self.dock.drag = Some(DockDragState {
source_pane: header.source_pane,
source_type: header.source_type,
pointer: Some(pointer),
candidate_target: None,
accepted_preview: None,
});
}
}
}
if self.dock.drag.is_some() {
let geometry = crate::dock::preview::DockGeometry {
viewport: iced::Size::new(
self.settings.window.width,
self.settings.window.height,
),
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
};
let regions = self.dock.pane_grid.layout().pane_regions(
crate::dock::preview::PANE_SPACING,
geometry.grid_bounds().size(),
);
let source_type = self.dock.drag.map(|drag| drag.source_type);
let approved = crate::dock::preview::approved_target(
pointer, geometry, &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;
}
Message::BrushSizeChanged(size) => {
self.tool_state.brush_size = size;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushSizeRelative(delta) => {
self.tool_state.brush_size = (self.tool_state.brush_size + delta).clamp(1.0, 500.0);
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushOpacityChanged(opacity) => {
self.tool_state.brush_opacity = opacity;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushHardnessChanged(hardness) => {
self.tool_state.brush_hardness = hardness;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushFlowChanged(flow) => {
self.settings.tool_settings.brush_flow = flow;
let _ = self.settings.save();
}
Message::BrushSpacingChanged(spacing) => {
self.tool_state.brush_spacing = spacing;
self.settings.tool_settings.brush_spacing = spacing;
let _ = self.settings.save();
}
Message::SelectionToleranceChanged(tol) => {
self.settings.tool_settings.magic_wand_tolerance = tol as f32;
self.settings.tool_settings.flood_fill_tolerance = tol as f32;
let _ = self.settings.save();
}
Message::SelectionContiguousToggled(v) => {
self.settings.tool_settings.selection_contiguous = v;
let _ = self.settings.save();
}
Message::SelectionAntiAliasToggled(v) => {
self.settings.tool_settings.selection_anti_alias = v;
let _ = self.settings.save();
}
Message::SelectionFeatherChanged(r) => {
self.settings.tool_settings.select_feather = r;
let _ = self.settings.save();
}
Message::SprayParticleSizeChanged(s) => {
self.settings.tool_settings.spray_particle_size = s;
let _ = self.settings.save();
}
Message::SprayDensityChanged(d) => {
self.settings.tool_settings.spray_density = d;
let _ = self.settings.save();
}
Message::GradientTypeChanged(t) => {
self.settings.tool_settings.gradient_type = t;
let _ = self.settings.save();
}
Message::VectorStrokeChanged(s) => {
self.settings.tool_settings.vector_stroke_width = s;
crate::shape_sync::sync_shape_from_tool(self);
let _ = self.settings.save();
}
Message::VectorOpacityChanged(o) => {
self.settings.tool_settings.vector_opacity = o;
crate::shape_sync::sync_shape_from_tool(self);
let _ = self.settings.save();
}
Message::VectorFillToggled(f) => {
self.settings.tool_settings.vector_fill = f;
crate::shape_sync::sync_shape_from_tool(self);
let _ = self.settings.save();
}
Message::VectorRadiusChanged(r) => {
self.settings.tool_settings.vector_radius = r;
crate::shape_sync::sync_shape_from_tool(self);
let _ = self.settings.save();
}
Message::VectorPointsChanged(p) => {
self.settings.tool_settings.vector_points = p;
crate::shape_sync::sync_shape_from_tool(self);
let _ = self.settings.save();
}
Message::VectorSidesChanged(s) => {
self.settings.tool_settings.vector_sides = s;
crate::shape_sync::sync_shape_from_tool(self);
let _ = self.settings.save();
}
Message::TextFontChanged(font) => {
self.settings.tool_settings.text_font = font.clone();
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
draft.font = font;
}
let _ = self.settings.save();
}
Message::TextSizeChanged(size) => {
self.settings.tool_settings.text_size = size;
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
draft.size = size;
}
let _ = self.settings.save();
}
Message::TextAngleChanged(angle) => {
self.settings.tool_settings.text_angle = angle;
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
draft.angle = angle;
}
let _ = self.settings.save();
}
Message::TextColorChanged(color) => {
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
draft.color = color;
}
}
Message::TextAlignChanged(alignment) => {
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
draft.alignment = match alignment.as_str() {
"Center" => hcie_engine_api::TextAlignment::Center,
"Right" => hcie_engine_api::TextAlignment::Right,
"Justify" => hcie_engine_api::TextAlignment::Justify,
_ => hcie_engine_api::TextAlignment::Left,
};
}
}
Message::TextOrientationChanged(orientation) => {
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
draft.orientation = match orientation.as_str() {
"Vertical" => hcie_engine_api::TextOrientation::Vertical,
_ => hcie_engine_api::TextOrientation::Horizontal,
};
}
}
Message::TextContentChanged(content) => {
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
draft.editor = iced::widget::text_editor::Content::with_text(&content);
draft.content = content;
}
}
Message::TextEditorAction(action) => {
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
draft.editor.perform(action);
draft.content = draft.editor.text();
}
}
Message::TextCommit => {
let draft = self.active_document_mut().text_draft.take();
if let Some(draft) = draft {
if !draft.content.is_empty() {
let engine = &mut self.active_document_mut().engine;
engine.add_text(
&draft.content,
&draft.font,
draft.size,
draft.x,
draft.y,
draft.color,
draft.angle,
draft.alignment,
draft.orientation,
&[],
);
self.active_document_mut().modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
}
Message::TextAccept => {
// Alias of TextCommit for existing panel wiring.
return self.update(Message::TextCommit);
}
Message::TextCancel => {
self.active_document_mut().text_draft = None;
}
Message::ToolApplyToNewLayerToggled(v) => {
self.settings.tool_settings.tool_apply_to_new_layer = v;
let _ = self.settings.save();
}
Message::ResetToolDefaults => {
self.settings.reset_tool_defaults();
self.settings.apply_to_tool_state(&mut self.tool_state);
let _ = self.settings.save();
}
Message::BrushImportAbr => {
log::info!("Import ABR brush requested");
let task = Task::perform(
async {
let result = rfd::AsyncFileDialog::new()
.add_filter("ABR Brush Files", &["abr"])
.add_filter("All Files", &["*"])
.set_title("Import ABR Brush")
.pick_file()
.await
.map(|handle| handle.path().to_path_buf());
match result {
Some(path) => {
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
match std::fs::read(&path) {
Ok(data) => {
let presets = crate::brush_import::import_abr_with_prefix(
&data, &file_name,
);
Ok(presets)
}
Err(e) => Err(format!("Failed to read file: {}", e)),
}
}
None => Err("No file selected".to_string()),
}
},
|result| Message::BrushImportAbrFile(result),
);
return task;
}
Message::BrushImportAbrFile(result) => {
match result {
Ok(presets) => {
log::info!("Imported {} brush presets", presets.len());
// Store imported presets in the tool state
self.tool_state.imported_brushes.extend(presets);
}
Err(e) => {
log::error!("Failed to import ABR brush: {}", e);
}
}
}
// ── Filters ───────────────────────────────────
Message::FilterSelect(filter) => {
self.restore_preview();
self.selected_filter = Some(filter);
self.filter_preview_active = self.documents[self.active_doc]
.engine
.active_layer_is_editable()
&& self.begin_preview();
self.filter_params = serde_json::json!({});
}
Message::FilterParamChanged(key, value) => {
if let serde_json::Value::Object(ref mut map) = self.filter_params {
if let serde_json::Value::Object(values) = value {
map.extend(values);
} else {
map.insert(key, value);
}
}
if self.filter_preview_active && self.reset_preview_to_baseline() {
let Some(filter) = self.selected_filter else {
return Task::none();
};
let filter_id = filter_type_to_id(filter);
self.documents[self.active_doc]
.engine
.apply_filter_preview(filter_id, self.filter_params.clone());
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::FilterApply => {
if let Some(filter) = self.selected_filter {
if !self.documents[self.active_doc]
.engine
.active_layer_is_editable()
{
log::warn!("filter apply blocked for locked, group, or mask layer");
return Task::none();
}
self.reset_preview_to_baseline();
let filter_id = filter_type_to_id(filter);
self.documents[self.active_doc]
.engine
.apply_filter(filter_id, self.filter_params.clone());
self.preview_baseline = None;
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
self.filter_preview_active = false;
self.selected_filter = None;
self.filter_params = serde_json::json!({});
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::FilterReset => {
self.filter_params = serde_json::json!({});
if self.filter_preview_active && self.reset_preview_to_baseline() {
if let Some(filter) = self.selected_filter {
self.documents[self.active_doc].engine.apply_filter_preview(
filter_type_to_id(filter),
self.filter_params.clone(),
);
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::FilterPreviewToggle(enabled) => {
self.filter_preview_active = enabled;
if enabled {
if self.begin_preview() {
let Some(filter) = self.selected_filter else {
return Task::none();
};
let filter_id = filter_type_to_id(filter);
self.documents[self.active_doc]
.engine
.apply_filter_preview(filter_id, self.filter_params.clone());
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
} else {
self.restore_preview();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::FilterPreviewApply => {
return self.update(Message::FilterApply);
}
Message::FilterPreviewRestore => {
if self.filter_preview_active {
self.restore_preview();
self.refresh_composite_if_needed();
self.filter_preview_active = false;
self.selected_filter = None;
self.filter_params = serde_json::json!({});
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
// ── Dialogs ───────────────────────────────────
Message::DialogOpen(dialog) => {
if matches!(
dialog,
ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation
) {
self.dialog_brightness = 0.0;
self.dialog_contrast = 0.0;
self.dialog_hue = 0.0;
self.dialog_saturation = 0.0;
self.dialog_lightness = 0.0;
self.begin_preview();
}
self.active_dialog = dialog;
}
Message::DialogClose => {
if matches!(
self.active_dialog,
ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation
) && self.restore_preview()
{
self.refresh_composite_if_needed();
}
self.active_dialog = ActiveDialog::None;
self.pending_document_close = None;
}
Message::DialogCloseSave => {
if let Some(path) = self.documents[self.active_doc].source_path.clone() {
match self.save_document_to(self.active_doc, &path) {
Ok(()) => {
self.finish_successful_save(self.active_doc, path);
return self.continue_close_after_save();
}
Err(error) => {
log::error!("Failed to save before close: {error}");
return Task::none();
}
}
}
self.pending_file_operation =
Some(PendingFileOperation::SaveAs { close_after: true });
self.active_dialog = ActiveDialog::None;
return self.update(Message::SaveFileAs);
}
Message::DialogCloseDiscard => {
self.documents[self.active_doc].modified = false;
return self.continue_close_after_save();
}
// ── New Image Dialog ──────────────────────────
Message::DialogNewImageName(name) => {
self.dialog_new_name = name;
}
Message::DialogNewImageWidth(w) => {
self.dialog_new_width = w;
}
Message::DialogNewImageHeight(h) => {
self.dialog_new_height = h;
}
Message::DialogNewImageTransparent(t) => {
self.dialog_new_transparent = t;
}
Message::DialogNewImagePreset(w, h) => {
self.dialog_new_width = w;
self.dialog_new_height = h;
}
Message::DialogNewImageCreate => {
let w = self.dialog_new_width;
let h = self.dialog_new_height;
let name = self.dialog_new_name.clone();
let transparent = self.dialog_new_transparent;
let mut engine = Engine::new_with_options(&name, w, h, transparent);
engine.pre_tile_all_layers();
let composite_raw = engine.get_composite_pixels();
let cached_layers = engine.layer_infos();
let history_len = engine.history_len();
let cached_history: Vec<(usize, String)> = (0..history_len)
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
.collect();
let history_current = engine.history_current();
let doc = IcedDocument {
engine,
composite_pixels: Arc::new(composite_raw.clone()),
composite_raw,
name,
source_path: None,
modified: false,
zoom: 1.0,
pan_offset: Vector::ZERO,
cached_layers,
cached_history,
history_current,
selection_rect: None,
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
selection_transform: None,
transform_drag_handle: TransformHandle::None,
transform_drag_start: None,
transform_original: None,
transform_source: None,
transform_layer_baseline: None,
transform_selection_baseline: None,
transform_preview_base: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
selection_history_marker: 0,
crop_state: CropState::default(),
lasso_points: Vec::new(),
polygon_points: Vec::new(),
gradient_drag: None,
vision_rect: None,
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selection_fill_spans: None,
selection_mode: selection::SelectionMode::Replace,
saved_selection_mask: None,
quick_mask: false,
selected_vector_shape: None,
vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
};
if self.show_welcome && self.documents.len() == 1 {
// Replace the placeholder empty document created for the
// welcome screen instead of appending a second tab.
self.documents = vec![doc];
self.active_doc = 0;
} else {
self.documents.push(doc);
self.active_doc = self.documents.len() - 1;
}
self.show_welcome = false;
self.active_dialog = ActiveDialog::None;
}
// ── Adjustments Dialog ────────────────────────
Message::AdjBrightness(v) => {
self.dialog_brightness = v;
let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast});
self.reset_preview_to_baseline();
self.documents[self.active_doc]
.engine
.apply_filter_preview("brightness_contrast", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjContrast(v) => {
self.dialog_contrast = v;
let params =
serde_json::json!({"brightness": self.dialog_brightness, "contrast": v});
self.reset_preview_to_baseline();
self.documents[self.active_doc]
.engine
.apply_filter_preview("brightness_contrast", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjHue(v) => {
self.dialog_hue = v;
let params = serde_json::json!({"hue": v, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": self.dialog_lightness});
self.reset_preview_to_baseline();
self.documents[self.active_doc]
.engine
.apply_filter_preview("hue_saturation", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjSaturation(v) => {
self.dialog_saturation = v;
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + v / 100.0, "lightness": self.dialog_lightness});
self.reset_preview_to_baseline();
self.documents[self.active_doc]
.engine
.apply_filter_preview("hue_saturation", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjLightness(v) => {
self.dialog_lightness = v;
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": v});
self.reset_preview_to_baseline();
self.documents[self.active_doc]
.engine
.apply_filter_preview("hue_saturation", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjApply => {
// Apply the adjustment permanently (not just preview)
let params = match &self.active_dialog {
ActiveDialog::BrightnessContrast => {
serde_json::json!({"brightness": self.dialog_brightness, "contrast": self.dialog_contrast})
}
ActiveDialog::HueSaturation => {
serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": self.dialog_lightness})
}
_ => serde_json::json!({}),
};
self.reset_preview_to_baseline();
let filter_id = match &self.active_dialog {
ActiveDialog::BrightnessContrast => "brightness_contrast",
ActiveDialog::HueSaturation => "hue_saturation",
_ => "",
};
if !filter_id.is_empty() {
self.documents[self.active_doc]
.engine
.apply_filter(filter_id, params);
self.preview_baseline = None;
self.documents[self.active_doc].modified = true;
}
self.refresh_composite_if_needed();
self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// ── Selection Dialog ──────────────────────────
Message::SelectionOpValue(v) => {
self.dialog_selection_value = v;
}
Message::SelectionOpApply => {
let v = self.dialog_selection_value as u32;
match &self.active_dialog {
ActiveDialog::SelectionOp(op) => match *op {
"Grow" => self.documents[self.active_doc].engine.selection_grow(v),
"Shrink" => self.documents[self.active_doc].engine.selection_shrink(v),
"Feather" => self.documents[self.active_doc]
.engine
.selection_feather(v as f32),
"Border" => self.documents[self.active_doc].engine.selection_border(v),
"Smooth" => self.documents[self.active_doc].engine.selection_smooth(v),
_ => {}
},
_ => {}
}
// Selection operations mutate the existing mask in place, so force overlay cache
// synchronization before the next marching-ants frame.
self.refresh_composite_if_needed();
self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// ── Image Size Dialog ────────────────────────
Message::DialogImageSizeWidth(w) => {
self.dialog_image_size_width = w;
if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 {
self.dialog_image_size_height =
(w as f32 / self.dialog_image_size_original_ratio) as u32;
}
}
Message::DialogImageSizeHeight(h) => {
self.dialog_image_size_height = h;
if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 {
self.dialog_image_size_width =
(h as f32 * self.dialog_image_size_original_ratio) as u32;
}
}
Message::DialogImageSizeConstrain(c) => {
self.dialog_image_size_constrain = c;
}
Message::DialogImageSizeApply => {
let w = self.dialog_image_size_width.max(1);
let h = self.dialog_image_size_height.max(1);
self.documents[self.active_doc].engine.resize_canvas(w, h);
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.refresh_composite_if_needed();
self.documents[self.active_doc].full_upload.replace(true);
self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// ── Canvas Size Dialog ───────────────────────
Message::DialogCanvasSizeWidth(w) => {
self.dialog_canvas_size_width = w;
}
Message::DialogCanvasSizeHeight(h) => {
self.dialog_canvas_size_height = h;
}
Message::DialogCanvasSizeAnchor(r, c) => {
self.dialog_canvas_size_anchor = (r, c);
}
Message::DialogCanvasSizeApply => {
let new_w = self.dialog_canvas_size_width.max(1);
let new_h = self.dialog_canvas_size_height.max(1);
self.documents[self.active_doc]
.engine
.resize_canvas(new_w, new_h);
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.refresh_composite_if_needed();
self.documents[self.active_doc].full_upload.replace(true);
self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// ── Selection tools ───────────────────────────
Message::SelectionDragStart(x, y) => {
self.documents[self.active_doc].selection_rect = Some((x, y, x, y));
}
Message::SelectionDragMove(x, y) => {
if let Some((x0, y0, _, _)) = self.documents[self.active_doc].selection_rect {
self.documents[self.active_doc].selection_rect = Some((x0, y0, x, y));
}
}
Message::SelectionDragEnd => {
if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect {
let sx = x0.min(x1) as u32;
let sy = y0.min(y1) as u32;
let ex = x0.max(x1) as u32;
let ey = y0.max(y1) as u32;
if ex > sx && ey > sy {
let previous = self.documents[self.active_doc]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
self.documents[self.active_doc]
.engine
.create_selection_rect(sx, sy, ex, ey);
self.combine_current_selection(previous);
// Store selection bounds for marching ants display
self.documents[self.active_doc].selection_bounds =
Some((sx, sy, ex - sx, ey - sy));
// Clear the drag-preview rect so the blue rectangle
// disappears and the marching ants overlay takes over.
self.documents[self.active_doc].selection_rect = None;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
// Clear selection rect if no valid selection was made
self.documents[self.active_doc].selection_rect = None;
}
Message::SelectAll => {
let previous = self.documents[self.active_doc].engine.get_selection_mask().map(ToOwned::to_owned);
let current_marker = self.documents[self.active_doc].engine.history_current();
if current_marker != self.documents[self.active_doc].selection_history_marker {
self.documents[self.active_doc].selection_history.clear();
self.documents[self.active_doc].selection_history_marker = current_marker;
}
self.documents[self.active_doc].selection_history.push(previous);
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
self.documents[self.active_doc].engine.selection_all();
self.documents[self.active_doc].selection_bounds = Some((0, 0, w, h));
}
Message::Deselect => {
let doc = &mut self.documents[self.active_doc];
let previous = doc.engine.get_selection_mask().map(ToOwned::to_owned);
let current_marker = doc.engine.history_current();
if current_marker != doc.selection_history_marker {
doc.selection_history.clear();
doc.selection_history_marker = current_marker;
}
doc.selection_history.push(previous);
doc.selection_rect = None;
doc.selection_bounds = None;
doc.selection_transform = None;
doc.transform_drag_handle = TransformHandle::None;
doc.engine.selection_clear();
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
doc.selection_fill_spans = None;
doc.marching_ants_edges = None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::SelectInverse => {
self.documents[self.active_doc].engine.selection_invert();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::SelectionModeChanged(mode) => {
self.documents[self.active_doc].selection_mode = mode;
}
Message::SelectionSave => {
self.documents[self.active_doc].saved_selection_mask = self.documents
[self.active_doc]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
}
Message::SelectionLoad => {
if let Some(mask) = self.documents[self.active_doc].saved_selection_mask.clone() {
self.documents[self.active_doc]
.engine
.set_selection_mask(mask);
self.refresh_composite_if_needed();
}
}
Message::QuickMaskToggle => {
let doc = &mut self.documents[self.active_doc];
doc.quick_mask = !doc.quick_mask;
}
Message::ClearPixels => {
self.documents[self.active_doc]
.engine
.clear_selection_pixels();
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
}
// ── Selection transform ────────────────────────
Message::TransformDragStart(x, y) => {
let doc = &mut self.documents[self.active_doc];
if let Some(ref tr) = doc.selection_transform {
let original = tr.clone();
doc.transform_drag_start = Some((x, y));
doc.transform_original = Some(original);
}
}
Message::TransformDragMove(x, y) => {
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
let previous_preview_bounds = doc
.selection_transform
.as_ref()
.and_then(|tr| preview_bounds(tr, canvas_w, canvas_h));
if let (Some(start), Some(ref mut tr)) =
(doc.transform_drag_start, &mut doc.selection_transform)
{
let dx = x - start.0;
let dy = y - start.1;
match doc.transform_drag_handle {
TransformHandle::Move => {
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::TopLeft
| TransformHandle::TopRight
| TransformHandle::BottomLeft
| TransformHandle::BottomRight
| TransformHandle::Top
| TransformHandle::Bottom
| TransformHandle::Left
| TransformHandle::Right => {
tr.resize_from_delta(doc.transform_drag_handle, dx, dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Rotate => {
if let Some(original) = doc.transform_original.as_ref() {
tr.rotation = selection::transform::rotation_from_drag(
original,
start,
(x, y),
);
}
}
TransformHandle::None => {}
}
tr.sanitize_geometry(self.modifiers.shift());
}
let doc = &mut self.documents[self.active_doc];
render_transform_preview(doc, previous_preview_bounds);
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::TransformDragEnd => {
let doc = &mut self.documents[self.active_doc];
doc.transform_drag_start = None;
doc.transform_original = None;
}
Message::TransformApply => {
let tr_taken = self.documents[self.active_doc].selection_transform.take();
if let Some(tr) = tr_taken {
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
let was_move = doc.transform_source.is_some();
let before_pixels = doc
.transform_layer_baseline
.take()
.or_else(|| doc.engine.get_active_layer_pixels());
let Some(before_pixels) = before_pixels else {
return Task::none();
};
apply_transform_to_layer(
&mut doc.engine,
&tr,
canvas_w,
canvas_h,
before_pixels,
if was_move {
"Move Selection"
} else {
"Transform Apply"
},
);
doc.engine.selection_clear();
doc.selection_bounds = None;
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
doc.transform_drag_handle = TransformHandle::None;
doc.transform_drag_start = None;
doc.transform_original = None;
doc.transform_source = None;
doc.transform_selection_baseline = None;
doc.transform_preview_base = None;
doc.modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::TransformCancel => {
let doc = &mut self.documents[self.active_doc];
log::debug!(
"[TransformCancel] begin restore_layer={}, restore_selection={}",
doc.transform_source.is_some() && doc.transform_layer_baseline.is_some(),
doc.transform_selection_baseline.is_some()
);
let original_layer = doc.transform_layer_baseline.take();
if doc.transform_source.is_some() {
if let Some(original_layer) = original_layer {
doc.engine.set_active_layer_pixels(original_layer);
}
}
if let Some(mask) = doc.transform_selection_baseline.take() {
doc.engine.set_selection_mask(mask.clone());
doc.selection_mask = Some(mask);
doc.selection_mask_dirty.set(true);
}
doc.selection_transform = None;
doc.transform_source = None;
doc.transform_original = None;
doc.transform_drag_start = None;
doc.transform_drag_handle = TransformHandle::None;
doc.transform_preview_base = None;
self.refresh_composite_if_needed();
log::debug!("[TransformCancel] end without history snapshot");
}
// ── Vector tools ──────────────────────────────
Message::VectorDrawStart(x, y) => {
self.documents[self.active_doc].vector_draw = Some(((x, y), (x, y)));
}
Message::VectorDrawMove(x, y) => {
if let Some((start, _)) = self.documents[self.active_doc].vector_draw {
self.documents[self.active_doc].vector_draw = Some((start, (x, y)));
}
}
Message::VectorDrawEnd => {
if let Some(((x0, y0), (x1, y1))) =
self.documents[self.active_doc].vector_draw.take()
{
// `add_vector_shape` safely auto-creates a vector layer from a non-vector
// layer, but would otherwise append to a locked active vector layer.
let active_id = self.documents[self.active_doc].engine.active_layer_id();
let active_is_locked_vector = self.documents[self.active_doc]
.engine
.get_layer_info(active_id)
.map(|info| {
info.locked && info.layer_type == hcie_engine_api::LayerType::Vector
})
.unwrap_or(false);
if active_is_locked_vector {
let safe_source = self.documents[self.active_doc]
.engine
.layer_infos()
.into_iter()
.find(|info| {
!info.locked
&& !matches!(
info.layer_type,
hcie_engine_api::LayerType::Vector
| hcie_engine_api::LayerType::Group
| hcie_engine_api::LayerType::Mask
)
})
.map(|info| info.id);
let Some(safe_source) = safe_source else {
return Task::none();
};
self.documents[self.active_doc]
.engine
.set_active_layer(safe_source);
}
let engine = &mut self.documents[self.active_doc].engine;
let color = self.fg_color;
let stroke = self.settings.tool_settings.vector_stroke_width;
let fill = self.settings.tool_settings.vector_fill;
let opacity = self.settings.tool_settings.vector_opacity;
let radius = self.settings.tool_settings.vector_radius;
let points = self.settings.tool_settings.vector_points;
let sides = self.settings.tool_settings.vector_sides;
let fill_color = color;
let shape = match self.tool_state.active_tool {
Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
radius,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorCircle => Some(hcie_engine_api::VectorShape::Circle {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorLine => Some(hcie_engine_api::VectorShape::Line {
x1: x0,
y1: y0,
x2: x1,
y2: y1,
stroke,
color,
angle: 0.0,
cap_start: hcie_engine_api::LineCap::Round,
cap_end: hcie_engine_api::LineCap::Round,
opacity,
hardness: 0.5,
}),
Tool::VectorArrow => Some(hcie_engine_api::VectorShape::Arrow {
x1: x0,
y1: y0,
x2: x1,
y2: y1,
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
thick: false,
}),
Tool::VectorStar => Some(hcie_engine_api::VectorShape::Star {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
points,
inner_radius: 0.5,
}),
Tool::VectorPolygon => Some(hcie_engine_api::VectorShape::Polygon {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
sides,
}),
Tool::VectorRhombus => Some(hcie_engine_api::VectorShape::Rhombus {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorCylinder => Some(hcie_engine_api::VectorShape::Cylinder {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorHeart => Some(hcie_engine_api::VectorShape::Heart {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorBubble => Some(hcie_engine_api::VectorShape::Bubble {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorGear => Some(hcie_engine_api::VectorShape::Gear {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorCross => Some(hcie_engine_api::VectorShape::Cross {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorCrescent => Some(hcie_engine_api::VectorShape::Crescent {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorBolt => Some(hcie_engine_api::VectorShape::Bolt {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
Tool::VectorArrow4 => Some(hcie_engine_api::VectorShape::Arrow4 {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
stroke,
color,
fill,
fill_color,
angle: 0.0,
opacity,
hardness: 0.5,
}),
_ => None,
};
if let Some(shape) = shape {
engine.add_vector_shape(shape);
self.documents[self.active_doc].modified = true;
}
self.refresh_composite_if_needed();
}
}
// ── Vector select / edit ─────────────────────
Message::VectorSelectClick { x, y } => {
use hcie_engine_api::VectorEditHandle as VEH;
// If a shape is already selected, hit-test its edit handles first.
if self.documents[self.active_doc]
.selected_vector_shape
.is_some()
{
let handle = self.vector_hit_test_handle(x, y);
if handle != VEH::None && handle != VEH::Move {
// Clicked on a resize/rotate handle — start drag with that handle.
let doc = &mut self.documents[self.active_doc];
let layer_id = doc.engine.active_layer_id();
let shape_index = doc.selected_vector_shape.unwrap();
if let Some(original) = doc
.engine
.active_vector_shapes()
.and_then(|shapes| shapes.get(shape_index).cloned())
{
doc.vector_edit_handle = handle;
self.vector_drag_last = Some((x, y));
self.vector_drag_session =
Some(crate::vector_edit::VectorDragSession {
layer_id,
shape_index,
handle,
pointer_start: (x, y),
original,
});
}
return Task::none();
}
}
// Find ALL shapes at the click position for cycling support.
// Collect indices of shapes whose bounding box contains (x, y),
// iterating forward so index 0 is the bottom-most shape.
if let Some(shapes) = self.documents[self.active_doc]
.engine
.active_vector_shapes()
{
let hits: Vec<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;
self.vector_shapes_snapshot = None;
self.vector_drag_last = None;
self.vector_drag_session = None;
}
} else {
// No vector shapes on this layer — deselect.
let doc = &mut self.documents[self.active_doc];
doc.selected_vector_shape = None;
doc.vector_cycle_index = 0;
doc.vector_edit_handle = VEH::None;
self.vector_shapes_snapshot = None;
self.vector_drag_last = None;
self.vector_drag_session = None;
}
}
Message::VectorSelectDragStart { x, y } => {
if self.documents[self.active_doc]
.selected_vector_shape
.is_some()
{
// Hit-test vector edit handles to determine drag mode.
let handle = self.vector_hit_test_handle(x, y);
let doc = &mut self.documents[self.active_doc];
let layer_id = doc.engine.active_layer_id();
let shape_index = doc.selected_vector_shape.unwrap();
if let Some(original) = doc
.engine
.active_vector_shapes()
.and_then(|shapes| shapes.get(shape_index).cloned())
{
doc.vector_edit_handle = handle;
self.vector_drag_last = Some((x, y));
self.vector_drag_session = Some(crate::vector_edit::VectorDragSession {
layer_id,
shape_index,
handle,
pointer_start: (x, y),
original,
});
}
}
}
Message::VectorSelectDragMove { x, y } => {
if let Some(session) = self.vector_drag_session.clone() {
let active_layer = self.documents[self.active_doc].engine.active_layer_id();
if active_layer != session.layer_id
|| self.documents[self.active_doc].selected_vector_shape
!= Some(session.shape_index)
{
self.vector_drag_last = None;
self.vector_drag_session = None;
return Task::none();
}
let shape = crate::vector_edit::transform_shape(
&session,
(x, y),
self.modifiers.shift(),
self.modifiers.alt(),
);
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle();
if let Some(shapes) = self.documents[self.active_doc]
.engine
.get_layer_shapes_direct(session.layer_id)
{
if let Some(current) = shapes.get_mut(session.shape_index) {
*current = shape;
}
}
self.documents[self.active_doc].modified = true;
self.vector_drag_last = Some((x, y));
let should_render = self
.vector_drag_last_render
.is_none_or(|last| last.elapsed() >= std::time::Duration::from_millis(16));
if should_render {
let engine = &mut self.documents[self.active_doc].engine;
engine.set_vector_shape_bounds(
session.layer_id,
session.shape_index,
x1,
y1,
x2,
y2,
);
engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle);
self.vector_drag_last_render = Some(std::time::Instant::now());
self.refresh_composite_if_needed();
}
}
}
Message::VectorSelectDragEnd => {
if self.vector_drag_last.is_some() {
if let Some(session) = self.vector_drag_session.as_ref() {
if let Some(shape) = self.documents[self.active_doc]
.engine
.active_vector_shapes()
.and_then(|shapes| shapes.get(session.shape_index).cloned())
{
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle();
let engine = &mut self.documents[self.active_doc].engine;
engine.set_vector_shape_bounds(
session.layer_id,
session.shape_index,
x1,
y1,
x2,
y2,
);
engine.set_vector_shape_angle(
session.layer_id,
session.shape_index,
angle,
);
}
}
// Commit the move/resize/rotate: if shapes changed, push history snapshot.
self.vector_drag_last = None;
self.vector_drag_last_render = None;
self.vector_drag_session = None;
self.documents[self.active_doc].vector_edit_handle =
hcie_engine_api::VectorEditHandle::None;
self.refresh_composite_if_needed();
}
}
Message::VectorShapeDelete => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc]
.engine
.delete_vector_shape(layer_id, idx);
self.documents[self.active_doc].selected_vector_shape = None;
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].vector_cycle_index = 0;
self.vector_shapes_snapshot = None;
self.vector_drag_session = None;
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
} else {
return self.update(Message::Deselect);
}
}
Message::VectorShapeSelect(idx) => {
self.documents[self.active_doc].selected_vector_shape = idx;
if idx.is_some() {
self.documents[self.active_doc].vector_cycle_index = 0;
let doc = &self.documents[self.active_doc];
if let Some(shapes) = doc.engine.active_vector_shapes() {
self.vector_shapes_snapshot =
Some(crate::vector_edit::VectorEditSnapshot {
layer_id: doc.engine.active_layer_id(),
shapes,
});
}
crate::shape_sync::sync_tool_from_shape(self);
} else {
self.documents[self.active_doc].vector_cycle_index = 0;
self.vector_shapes_snapshot = None;
}
self.vector_drag_session = None;
}
// ── Vector shape apply / cancel / flip ──────
Message::VectorShapeApply => {
// Commit edits: clear snapshot and deselect.
let doc = &mut self.documents[self.active_doc];
doc.selected_vector_shape = None;
doc.vector_cycle_index = 0;
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
self.vector_shapes_snapshot = None;
self.vector_drag_last = None;
self.vector_drag_session = None;
}
Message::VectorShapeCancel => {
// Restore snapshot: put original shapes back, then deselect.
if let Some(snapshot) = self.vector_shapes_snapshot.take() {
let doc = &mut self.documents[self.active_doc];
if let Some(shapes) = doc.engine.get_layer_shapes_direct(snapshot.layer_id) {
*shapes = snapshot.shapes;
}
doc.selected_vector_shape = None;
doc.vector_cycle_index = 0;
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
self.vector_drag_last = None;
self.vector_drag_session = None;
doc.engine.mark_composite_dirty();
self.refresh_composite_if_needed();
}
}
Message::VectorShapeFlipH => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if let Some(shapes) = self.documents[self.active_doc]
.engine
.get_layer_shapes_direct(layer_id)
{
if let Some(shape) = shapes.get_mut(idx) {
match shape {
hcie_engine_api::VectorShape::FreePath { pts, .. } => {
if pts.is_empty() {
return Task::none();
}
let cx =
pts.iter().map(|p| p[0]).sum::<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::GeometryFillToggled(v) => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc]
.engine
.set_vector_shape_fill(layer_id, idx, v);
self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self);
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
}
}
Message::GeometryFillColorChanged(c) => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc]
.engine
.set_vector_shape_fill_color(layer_id, idx, c);
self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self);
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
}
}
Message::GeometryStrokeColorChanged(c) => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc]
.engine
.set_vector_shape_color(layer_id, idx, c);
self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self);
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
}
}
Message::GeometryOpacityChanged(v) => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc]
.engine
.set_vector_shape_opacity(layer_id, idx, v);
self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self);
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
}
}
Message::GeometryHardnessChanged(v) => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc]
.engine
.set_vector_shape_hardness(layer_id, idx, v);
self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self);
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
}
}
Message::GeometryBoolShapeA(idx) => {
self.bool_shape_a = idx;
}
Message::GeometryBoolShapeB(idx) => {
self.bool_shape_b = idx;
}
Message::GeometryBooleanOp(op) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if let (Some(a), Some(b)) = (self.bool_shape_a, self.bool_shape_b) {
let shape_count = self.documents[self.active_doc]
.engine
.active_vector_shapes()
.map_or(0, |shapes| shapes.len());
let valid = a < shape_count && b < shape_count && a != b;
let succeeded = valid
&& self.documents[self.active_doc]
.engine
.boolean_vector_shapes(layer_id, op, a, b);
if succeeded {
let result_index = shape_count - 2;
self.documents[self.active_doc].selected_vector_shape = Some(result_index);
self.documents[self.active_doc].vector_cycle_index = 0;
self.bool_shape_a = None;
self.bool_shape_b = None;
self.vector_shapes_snapshot = None;
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
crate::shape_sync::sync_tool_from_shape(self);
self.refresh_composite_if_needed();
}
}
}
Message::GeometryLineCapStart(cap) => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
let shapes = self.documents[self.active_doc]
.engine
.active_vector_shapes()
.unwrap_or_default();
if let Some(s) = shapes.get(idx) {
let ce = match s {
hcie_engine_api::VectorShape::Line { cap_end, .. } => *cap_end,
_ => hcie_engine_api::LineCap::Round,
};
self.documents[self.active_doc]
.engine
.set_vector_shape_line_caps(layer_id, idx, cap, ce);
self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self);
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
}
}
}
Message::GeometryLineCapEnd(cap) => {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
let shapes = self.documents[self.active_doc]
.engine
.active_vector_shapes()
.unwrap_or_default();
if let Some(s) = shapes.get(idx) {
let cs = match s {
hcie_engine_api::VectorShape::Line { cap_start, .. } => *cap_start,
_ => hcie_engine_api::LineCap::Round,
};
self.documents[self.active_doc]
.engine
.set_vector_shape_line_caps(layer_id, idx, cs, cap);
self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self);
self.documents[self.active_doc]
.engine
.mark_composite_dirty();
self.refresh_composite_if_needed();
}
}
}
// ── Lasso / polygon selection ─────────────────
Message::LassoDragMove(x, y) => {
let cx = x.round().max(0.0) as u32;
let cy = y.round().max(0.0) as u32;
let pts = &mut self.documents[self.active_doc].lasso_points;
// Throttle duplicate points to keep the polygon light.
if pts.last().map_or(true, |l| l.0 != cx || l.1 != cy) {
pts.push((cx, cy));
}
}
Message::LassoDragEnd => {
let pts = std::mem::take(&mut self.documents[self.active_doc].lasso_points);
if pts.len() >= 3 {
let previous = self.documents[self.active_doc]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
let pts_ref: Vec<(u32, u32)> = pts;
self.documents[self.active_doc]
.engine
.create_selection_lasso(&pts_ref);
self.combine_current_selection(previous);
self.documents[self.active_doc].selection_bounds = {
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
Some((0, 0, w, h))
};
self.refresh_composite_if_needed();
}
}
Message::PolygonAddPoint(x, y) => {
let cx = x.round().max(0.0) as u32;
let cy = y.round().max(0.0) as u32;
self.documents[self.active_doc]
.polygon_points
.push((cx, cy));
}
Message::PolygonClose => {
let pts = std::mem::take(&mut self.documents[self.active_doc].polygon_points);
if pts.len() >= 3 {
let previous = self.documents[self.active_doc]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
let pts_ref: Vec<(u32, u32)> = pts;
self.documents[self.active_doc]
.engine
.create_selection_polygon(&pts_ref);
self.combine_current_selection(previous);
self.documents[self.active_doc].selection_bounds = {
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
Some((0, 0, w, h))
};
self.refresh_composite_if_needed();
}
}
// ── Gradient tool ────────────────────────────
Message::GradientDragStart(x, y) => {
self.documents[self.active_doc].gradient_drag = Some(((x, y), (x, y)));
self.tool_state.is_drawing = true;
}
Message::GradientDragMove(x, y) => {
if let Some((start, _)) = self.documents[self.active_doc].gradient_drag {
self.documents[self.active_doc].gradient_drag = Some((start, (x, y)));
}
// Also drive the VisionSelect bbox preview.
if self.tool_state.active_tool == Tool::VisionSelect {
if let Some((x0, y0, _, _)) = self.documents[self.active_doc].vision_rect {
self.documents[self.active_doc].vision_rect = Some((x0, y0, x, y));
}
}
}
Message::GradientDragEnd => {
if let Some(((x0, y0), (x1, y1))) =
self.documents[self.active_doc].gradient_drag.take()
{
let doc = &mut self.documents[self.active_doc];
let width = doc.engine.canvas_width();
let height = doc.engine.canvas_height();
let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned);
if let Some(mut pixels) = doc.engine.get_active_layer_pixels() {
crate::raster::apply_gradient(
&mut pixels,
width,
height,
(x0, y0),
(x1, y1),
self.settings.tool_settings.gradient_type == 1,
self.fg_color,
self.bg_color,
self.settings.tool_settings.gradient_opacity,
mask.as_deref(),
);
doc.engine.set_active_layer_pixels(pixels);
doc.modified = true;
}
self.refresh_composite_if_needed();
}
}
// ── Crop tool ────────────────────────────────
Message::CropDragStart(x, y) => {
let width = self.documents[self.active_doc].engine.canvas_width();
let height = self.documents[self.active_doc].engine.canvas_height();
self.documents[self.active_doc]
.crop_state
.start_drag_clamped(x, y, width, height);
}
Message::CropDragMove(x, y) => {
self.documents[self.active_doc].crop_state.update_drag(x, y);
}
Message::CropDragEnd => {
self.documents[self.active_doc].crop_state.end_drag();
}
Message::CropConfirm => {
if let Some((x, y, w, h)) = self.documents[self.active_doc].crop_state.confirm() {
self.documents[self.active_doc].engine.crop(x, y, w, h);
self.documents[self.active_doc].crop_state.cancel();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::CropCancel => {
self.documents[self.active_doc].crop_state.cancel();
}
// ── Script Editor ──────────────────────────────
Message::ScriptEditorAction(action) => {
self.script_content.perform(action);
self.script_text = self.script_content.text();
}
Message::ScriptTextChanged(text) => {
self.script_text = text;
}
Message::ScriptRun => {
match crate::script::parser::parse_dsl_script(&self.script_text) {
Ok(actions) => {
self.script_error = None;
self.script_error_line = None;
self.script_is_running = true;
crate::script::executor::execute_actions(
&mut self.documents[self.active_doc].engine,
&actions,
);
self.script_is_running = false;
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => {
self.script_error_line = crate::script::parser::parse_error_line(&e);
self.script_error = Some(e);
}
}
}
Message::ScriptClear => {
self.script_text.clear();
self.script_error = None;
self.script_error_line = None;
}
// ── AI Chat ───────────────────────────────────
Message::AiChatInput(text) => {
self.ai_chat_state.input = text;
}
Message::AiChatSend => {
let user_msg = self.ai_chat_state.input.trim().to_string();
if user_msg.is_empty() || self.ai_chat_state.is_streaming {
return Task::none();
}
// Add user message to history
self.ai_chat_state.messages.push(ChatMessage {
role: "user".to_string(),
content: user_msg.clone(),
is_reasoning: false,
});
self.ai_chat_state.input.clear();
self.ai_chat_state.is_streaming = true;
self.ai_chat_state.current_response.clear();
self.ai_chat_state.current_reasoning.clear();
// Build the final prompt with optional canvas context
let mut final_prompt = user_msg.clone();
if self.ai_chat_state.config.canvas_context {
let doc = &self.documents[self.active_doc];
let w = doc.engine.canvas_width();
let h = doc.engine.canvas_height();
let layers = doc.engine.layer_infos();
let mut meta =
format!("\n\n[Active Canvas State: Size {}x{} px. Layers:", w, h);
for l in &layers {
meta.push_str(&format!(
"\n- \"{}\" (Type: {:?}, ID: {}, Visible: {}, Opacity: {:.0}%)",
l.name,
l.layer_type,
l.id,
l.visible,
l.opacity * 100.0
));
}
meta.push_str("]");
final_prompt.push_str(&meta);
}
// Build messages JSON for API
let messages_json = ai_chat::build_messages_json(
&self.ai_chat_state.messages,
&final_prompt,
SYSTEM_PROMPT,
);
// Clone config for the async task
let url = self.ai_chat_state.config.base_url.clone();
let model = self.ai_chat_state.config.model.clone();
let provider = self.ai_chat_state.config.provider.clone();
let task = Task::run(
ai_chat::stream_llm_response(provider, url, model, messages_json),
|update| match update {
ai_chat::ChatUpdate::Chunk(chunk) => Message::AiChatStreamChunk(chunk),
ai_chat::ChatUpdate::ReasoningChunk(chunk) => {
Message::AiChatReasoningChunk(chunk)
}
ai_chat::ChatUpdate::Finished(result) => {
Message::AiChatStreamFinished(result)
}
},
);
let (task, handle) = task.abortable();
self.ai_chat_abort = Some(handle);
return task;
}
Message::AiChatClear => {
self.ai_chat_state.messages.clear();
self.ai_chat_state.current_response.clear();
self.ai_chat_state.current_reasoning.clear();
self.ai_chat_state.is_streaming = false;
if let Some(handle) = self.ai_chat_abort.take() {
handle.abort();
}
}
Message::AiChatProviderChanged(provider) => match provider.as_str() {
"Ollama" => {
self.ai_chat_state.config.provider = ai_chat::AiProvider::Ollama;
self.ai_chat_state.config.base_url = "http://localhost:11434".to_string();
}
"Claude" => {
self.ai_chat_state.config.provider = ai_chat::AiProvider::Claude;
self.ai_chat_state.config.base_url = "https://api.anthropic.com".to_string();
}
"OpenAI" => {
self.ai_chat_state.config.provider = ai_chat::AiProvider::OpenAI;
self.ai_chat_state.config.base_url = "https://api.openai.com/v1".to_string();
}
_ => {}
},
Message::AiChatUrlChanged(url) => {
self.ai_chat_state.config.base_url = url;
}
Message::AiChatModelChanged(model) => {
self.ai_chat_state.config.model = model;
}
Message::AiChatReasoningToggled(enabled) => {
self.ai_chat_state.config.reasoning_enabled = enabled;
}
Message::AiChatCanvasContextToggled(enabled) => {
self.ai_chat_state.config.canvas_context = enabled;
}
Message::AiChatStreamChunk(chunk) => {
self.ai_chat_state.current_response.push_str(&chunk);
}
Message::AiChatReasoningChunk(chunk) => {
self.ai_chat_state.current_reasoning.push_str(&chunk);
}
Message::AiChatStreamFinished(result) => {
self.ai_chat_abort = None;
self.ai_chat_state.is_streaming = false;
match result {
Ok(full_response) => {
// Execute any embedded tool calls
let tool_results = ai_chat::execute_inline_tools(
&full_response,
&mut self.documents[self.active_doc].engine,
);
for res in &tool_results {
self.ai_chat_state.messages.push(ChatMessage {
role: "tool".to_string(),
content: res.clone(),
is_reasoning: false,
});
}
// Execute any DSL code blocks
if let Some(dsl_code) = ai_chat::extract_dsl_block(&full_response) {
let commands = ai_chat::parse_dsl_script(
&dsl_code,
&self.documents[self.active_doc].engine,
);
for cmd in &commands {
ai_chat::execute_command_on_engine(
&mut self.documents[self.active_doc].engine,
cmd,
);
}
self.ai_chat_state.messages.push(ChatMessage {
role: "tool".to_string(),
content: "▶ Automatically executed AI procedural drawing script."
.to_string(),
is_reasoning: false,
});
self.refresh_composite_if_needed();
}
// Add the full assistant response to history
self.ai_chat_state.messages.push(ChatMessage {
role: "assistant".to_string(),
content: full_response,
is_reasoning: false,
});
if !self.ai_chat_state.current_reasoning.is_empty() {
self.ai_chat_state.messages.push(ChatMessage {
role: "assistant".to_string(),
content: std::mem::take(&mut self.ai_chat_state.current_reasoning),
is_reasoning: true,
});
}
self.ai_chat_state.current_response.clear();
}
Err(e) => {
self.ai_chat_state.messages.push(ChatMessage {
role: "assistant".to_string(),
content: format!("❌ Connection Error: {}", e),
is_reasoning: false,
});
}
}
}
Message::AiChatCancel => {
if let Some(handle) = self.ai_chat_abort.take() {
handle.abort();
}
if !self.ai_chat_state.current_response.is_empty() {
self.ai_chat_state.messages.push(ChatMessage {
role: "assistant".to_string(),
content: std::mem::take(&mut self.ai_chat_state.current_response),
is_reasoning: false,
});
}
self.ai_chat_state.is_streaming = false;
}
Message::AiChatCopyTranscript => {
let mut messages = self.ai_chat_state.messages.clone();
if !self.ai_chat_state.current_response.is_empty() {
messages.push(ChatMessage {
role: "assistant".to_string(),
content: self.ai_chat_state.current_response.clone(),
is_reasoning: false,
});
}
if !self.ai_chat_state.current_reasoning.is_empty() {
messages.push(ChatMessage {
role: "assistant".to_string(),
content: self.ai_chat_state.current_reasoning.clone(),
is_reasoning: true,
});
}
return iced::clipboard::write(ai_chat::transcript_text(&messages));
}
// ── AI Script Generator ───────────────────────
Message::AiScriptPromptChanged(text) => {
self.ai_script_prompt = text;
}
Message::AiScriptProviderChanged(provider) => {
self.ai_script_provider = provider.clone();
// Set sensible defaults when switching providers
match provider.as_str() {
"ollama" => {
self.ai_script_url = "http://localhost:11434".to_string();
self.ai_script_model = "qwen2.5:7b".to_string();
}
"claude" => {
self.ai_script_url = "https://api.anthropic.com".to_string();
self.ai_script_model = "claude-sonnet-4-20250514".to_string();
}
"openai" => {
self.ai_script_url = "https://api.openai.com/v1".to_string();
self.ai_script_model = "gpt-4o".to_string();
}
_ => {}
}
}
Message::AiScriptModelChanged(model) => {
self.ai_script_model = model;
}
Message::AiScriptUrlChanged(url) => {
self.ai_script_url = url;
}
Message::AiScriptGenerate => {
if self.ai_script_prompt.trim().is_empty() || self.ai_script_is_running {
return Task::none();
}
self.ai_script_is_running = true;
self.ai_script_error = None;
self.ai_script_notice = None;
self.ai_script_output.clear();
let prompt = self.ai_script_prompt.clone();
let provider_type = self.ai_script_provider.clone();
let provider = crate::ai_script::Provider {
base_url: self.ai_script_url.clone(),
model: self.ai_script_model.clone(),
};
return Task::perform(
async move {
crate::ai_script::generate_script(&provider_type, &provider, &prompt).await
},
Message::AiScriptResult,
);
}
Message::AiScriptRun => {
if self.ai_script_output.is_empty() {
return Task::none();
}
match crate::script::parser::parse_dsl_script(&self.ai_script_output) {
Ok(actions) => {
self.ai_script_error = None;
self.ai_script_notice = Some(format!(
"Executed {} validated script actions.",
actions.len()
));
crate::script::executor::execute_actions(
&mut self.documents[self.active_doc].engine,
&actions,
);
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => {
self.ai_script_error = Some(e);
}
}
}
Message::AiScriptCopy => {
if !self.ai_script_output.is_empty() {
// Copy script to the script editor panel
self.script_text = self.ai_script_output.clone();
self.script_content =
iced::widget::text_editor::Content::with_text(&self.ai_script_output);
self.ai_script_notice =
Some("Copied generated script to the Script panel.".to_string());
}
}
Message::AiScriptCopyOutput => {
if !self.ai_script_output.is_empty() {
self.ai_script_notice =
Some("Copied generated script to the system clipboard.".to_string());
return iced::clipboard::write(self.ai_script_output.clone());
}
}
Message::AiScriptClear => {
self.ai_script_prompt.clear();
self.ai_script_output.clear();
self.ai_script_error = None;
self.ai_script_notice = None;
self.ai_script_is_running = false;
}
Message::AiScriptResult(result) => {
self.ai_script_is_running = false;
match result {
Ok(script) => {
self.ai_script_output = script;
self.ai_script_notice =
Some("Generated script validated and is ready to run.".to_string());
}
Err(e) => {
self.ai_script_error = Some(e);
}
}
}
// ── Clipboard ─────────────────────────────────
Message::CopyImage => {
let (pixels, w, h) = {
let doc = &self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
if let Some(mask) = doc.engine.get_selection_mask() {
if let Some(transform) = selection::clipboard::extract_masked_pixels(
&doc.composite_raw,
mask,
canvas_w,
canvas_h,
) {
let result =
(transform.pixels.clone(), transform.width, transform.height);
self.documents[self.active_doc].internal_clipboard = Some(transform);
result
} else {
(doc.composite_raw.clone(), canvas_w, canvas_h)
}
} else {
(doc.composite_raw.clone(), canvas_w, canvas_h)
}
};
return Task::perform(
async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) },
Message::ImageCopied,
);
}
Message::CutImage => {
let doc = &mut self.documents[self.active_doc];
let w = doc.engine.canvas_width();
let h = doc.engine.canvas_height();
let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned);
if let Some(mask) = mask {
if let Some(transform) =
selection::clipboard::extract_masked_pixels(&doc.composite_raw, &mask, w, h)
{
let _ = crate::io::clipboard::copy_image_to_clipboard(
&transform.pixels,
transform.width,
transform.height,
);
doc.internal_clipboard = Some(transform);
// Record an undoable snapshot of the source pixels before
// clearing them, then clear the selected region via the engine.
doc.engine.clear_selection_pixels();
doc.modified = true;
}
}
self.refresh_composite_if_needed();
}
Message::PasteImage => {
// Check internal clipboard first
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
{
// Enter transform mode with the clipboard content
let mut placed = transform;
placed.pos.x += 10.0;
placed.pos.y += 10.0;
let doc = &mut self.documents[self.active_doc];
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
doc.transform_selection_baseline =
doc.engine.get_selection_mask().map(ToOwned::to_owned);
doc.transform_source = None;
doc.transform_preview_base = Some(doc.composite_raw.clone());
doc.selection_transform = Some(placed);
doc.transform_drag_handle = TransformHandle::None;
render_transform_preview(doc, None);
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// Fall back to system clipboard
return Task::perform(
async { crate::io::clipboard::paste_image_from_clipboard() },
Message::ImagePasted,
);
}
Message::CopyText(text) => {
let text = text.clone();
return Task::perform(
async move { crate::io::clipboard::copy_text_to_clipboard(&text) },
|_| Message::NoOp,
);
}
Message::PasteText => {
return Task::perform(
async { crate::io::clipboard::paste_text_from_clipboard() },
Message::ClipboardResult,
);
}
Message::ClipboardResult(Ok(Some(text))) => {
let doc = &mut self.documents[self.active_doc];
let draft = doc.text_draft.get_or_insert_with(TextDraft::default);
draft.content.push_str(&text);
draft.editor = iced::widget::text_editor::Content::with_text(&draft.content);
}
Message::ClipboardResult(Ok(None)) => {
log::info!("Clipboard is empty");
}
Message::ClipboardResult(Err(e)) => {
log::error!("Clipboard error: {}", e);
}
Message::ImageCopied(Ok(())) => {
log::info!("Image copied to clipboard");
}
Message::ImageCopied(Err(e)) => {
log::error!("Failed to copy image: {}", e);
}
Message::ImagePasted(Ok(Some((pixels, w, h)))) => {
log::info!("Pasted image: {}x{}", w, h);
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
doc.transform_selection_baseline =
doc.engine.get_selection_mask().map(ToOwned::to_owned);
doc.transform_source = None;
doc.transform_preview_base = Some(doc.composite_raw.clone());
doc.selection_transform =
selection::clipboard::centered_transform(pixels, w, h, canvas_w, canvas_h);
render_transform_preview(doc, None);
}
Message::ImagePasted(Ok(None)) => {
log::info!("No image in clipboard");
}
Message::ImagePasted(Err(e)) => {
log::error!("Failed to paste image: {}", e);
}
// ── File I/O (with rfd) ───────────────────────
Message::OpenFileRfd => {
self.pending_file_operation = Some(PendingFileOperation::Open);
return Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Open Image")
.add_filter(
"All Supported Types",
&[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
"hcie", "psd", "kra",
],
)
.add_filter(
"Images",
&[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
],
)
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
},
Message::FileDialogClosed,
);
}
Message::SaveFileAs => {
if !matches!(
self.pending_file_operation,
Some(PendingFileOperation::SaveAs { close_after: true })
) {
self.pending_file_operation =
Some(PendingFileOperation::SaveAs { close_after: false });
}
return Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Save As")
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.add_filter("PNG", &["png"])
.add_filter("JPEG", &["jpg", "jpeg"])
.add_filter("WebP", &["webp"])
.add_filter("AVIF", &["avif"])
.add_filter("BMP", &["bmp"])
.add_filter("GIF", &["gif"])
.add_filter("TIFF", &["tiff", "tif"])
.add_filter("TGA", &["tga"])
.add_filter("OpenEXR", &["exr"])
.add_filter(
"All Supported Types",
&[
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
"avif", "tga", "exr", "psd", "kra", "hcie",
],
)
.save_file()
.await
.map(|handle| handle.path().to_path_buf())
},
Message::FileDialogClosed,
);
}
Message::FileDialogClosed(Some(path)) => {
let path_str = path.to_string_lossy().to_string();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
match self.pending_file_operation.take() {
Some(PendingFileOperation::Open) => {
let engine = &mut self.documents[self.active_doc].engine;
let result = match ext.as_str() {
"psd" => engine.import_psd(&path_str),
"kra" => engine.import_kra(&path_str),
"hcie" => engine.load_native(&path_str),
_ => engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.show_welcome = false;
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path.clone());
self.documents[self.active_doc].modified = false;
self.add_recent_file(&path);
self.documents[self.active_doc].full_upload.replace(true);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => log::error!("Failed to open: {}", e),
}
}
Some(PendingFileOperation::SaveAs { close_after }) => {
match self.save_document_to(self.active_doc, &path) {
Ok(()) => {
log::info!("Saved to {}", path_str);
self.finish_successful_save(self.active_doc, path);
if close_after {
return self.continue_close_after_save();
}
}
Err(error) => log::error!("Failed to save: {error}"),
}
}
None => log::warn!("Ignoring file dialog result without a pending operation"),
}
}
Message::FileDialogClosed(None) => {
self.pending_file_operation = None;
self.pending_document_close = None;
}
// ── Dock Layout ───────────────────────────────
Message::PaneClicked(pane) => {
self.dock.active_auto_hide = None;
self.dock.focus(pane);
}
Message::DockHeaderDragStart(pane) => {
if let Some(source_type) = self
.dock
.pane_type(pane)
.filter(|panel| *panel != PaneType::Canvas)
{
let origin = self
.last_cursor_pos
.map(|(x, y)| iced::Point::new(x, y))
.unwrap_or_default();
self.dock.header_drag = Some(
crate::dock::manager::DockHeaderDragState {
source_pane: pane,
source_type,
origin,
},
);
self.dock.focus(pane);
}
}
Message::PaneDragged(drag_event) => {
use iced::widget::pane_grid::DragEvent;
match drag_event {
DragEvent::Picked { pane } => {
if let Some(source_type) = self
.dock
.pane_type(pane)
.filter(|panel| *panel != PaneType::Canvas)
{
self.dock.drag = Some(DockDragState {
source_pane: pane,
source_type,
pointer: self.last_cursor_pos.map(|(x, y)| iced::Point::new(x, y)),
candidate_target: None,
accepted_preview: None,
});
}
}
DragEvent::Dropped { pane, target } => {
let approved = self.dock.drag.as_ref().is_some_and(|drag| {
drag.source_pane == pane
&& drag.candidate_target.is_some_and(|candidate| {
crate::dock::preview::target_matches(candidate, target)
&& crate::dock::manager::DockDropPolicy::accepts(
drag.source_type,
crate::dock::manager::DockDropZone::from_target(target),
)
})
});
self.dock.drag = None;
if approved && self.dock.apply_drop(pane, target) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
DragEvent::Canceled { .. } => self.dock.drag = None,
}
}
Message::PaneResized(resize_event) => {
let grid_size = self.dock.last_grid_size.unwrap_or_else(|| {
crate::dock::preview::DockGeometry {
viewport: iced::Size::new(
self.settings.window.width,
self.settings.window.height,
),
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
}
.grid_bounds()
.size()
});
let ratio = crate::dock::sizing::constrain_user_resize_ratio(
&self.dock.pane_grid,
resize_event.split,
resize_event.ratio,
grid_size,
);
self.dock.pane_grid.resize(resize_event.split, ratio);
self.dock.resize_persistence_dirty = true;
}
Message::PaneClose(pane) => {
// Don't close canvas panes
if let Some(PaneType::Canvas) = self.dock.pane_type(pane) {
return Task::none();
}
if self.dock.close_pane(pane) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::PaneMaximize(pane) => {
self.dock.toggle_maximize(pane);
}
Message::PaneAutoHide(pane) => {
if self
.dock
.auto_hide_pane(pane, Some(crate::dock::manager::DockEdge::Right))
{
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::PaneFloat(pane) => {
let viewport = (self.settings.window.width, self.settings.window.height);
if self.dock.float_pane(pane, viewport) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::FloatingFocus(panel) => {
self.dock.focus_floating(panel);
}
Message::FloatingDragStart(panel) => {
if let Some(rect) = self.dock.focus_floating(panel) {
let pointer = self.last_cursor_pos.unwrap_or((rect.x, rect.y));
self.dock.floating_drag = Some(crate::dock::floating::FloatingDragState {
panel,
pointer_offset: iced::Point::new(pointer.0 - rect.x, pointer.1 - rect.y),
});
self.dock.floating_target = None;
}
}
Message::FloatingRedock(panel) => {
if self.dock.redock_floating(panel) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::FloatingClose(panel) => {
if self.dock.close_floating(panel) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::FloatingAutoHide(panel) => {
if self
.dock
.auto_hide_floating(panel, crate::dock::manager::DockEdge::Right)
{
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
Message::DockViewportChanged(size) => {
self.settings.window.width = size.width;
self.settings.window.height = size.height;
self.dock.clamp_floating((size.width, size.height));
let grid_size = crate::dock::preview::DockGeometry {
viewport: size,
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
}
.grid_bounds()
.size();
self.dock.set_grid_size(grid_size);
}
Message::AutoHideActivate(panel) => {
self.dock.active_auto_hide = if self.dock.active_auto_hide == Some(panel) {
None
} else {
Some(panel)
};
}
Message::AutoHideDismiss => self.dock.active_auto_hide = None,
Message::AutoHideRestore(panel) => {
if self.dock.restore_auto_hidden(panel) {
self.settings
.panel_layout
.persist_current_placement(&self.dock);
let _ = self.settings.save();
}
}
// ── Dock Profiles ─────────────────────────────
Message::DockProfileSave(name) => {
let name = name.trim().to_string();
if name.is_empty() {
return Task::none();
}
// Collect the list of panel types currently open in the dock
let visible_panels: Vec<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: Arc::new(composite_raw.clone()),
composite_raw,
name: "Untitled".to_string(),
source_path: None,
modified: false,
zoom: 1.0,
pan_offset: Vector::ZERO,
cached_layers,
cached_history,
history_current,
selection_rect: None,
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
selection_transform: None,
transform_drag_handle: TransformHandle::None,
transform_drag_start: None,
transform_original: None,
transform_source: None,
transform_layer_baseline: None,
transform_selection_baseline: None,
transform_preview_base: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
selection_history_marker: 0,
crop_state: CropState::default(),
lasso_points: Vec::new(),
polygon_points: Vec::new(),
gradient_drag: None,
vision_rect: None,
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selection_fill_spans: None,
selection_mode: selection::SelectionMode::Replace,
saved_selection_mask: None,
quick_mask: false,
selected_vector_shape: None,
vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
});
}
Message::DocSwitch(idx) => {
if idx < self.documents.len() {
if self.preview_baseline.is_some() {
self.restore_preview();
self.filter_preview_active = false;
self.selected_filter = None;
self.filter_params = serde_json::json!({});
}
self.active_doc = idx;
}
}
Message::DocClose(idx) => {
let modified = self
.documents
.get(idx)
.is_some_and(|document| document.modified);
match document_close_disposition(self.documents.len(), idx, modified) {
DocumentCloseDisposition::Invalid => return Task::none(),
DocumentCloseDisposition::Welcome
| DocumentCloseDisposition::Confirm
| DocumentCloseDisposition::Close => {
if self.preview_baseline.is_some() {
self.restore_preview();
self.filter_preview_active = false;
}
}
}
if modified {
self.active_doc = idx;
self.pending_document_close = Some(idx);
self.active_dialog = ActiveDialog::CloseConfirm;
} else {
self.close_document_tab(idx);
}
}
Message::OpenFile => {
return self.update(Message::OpenFileRfd);
}
Message::FileOpened(Ok(path)) => {
log::info!("File opened: {:?}", path);
}
Message::FileOpened(Err(e)) => {
log::error!("Failed to open file: {}", e);
}
Message::SaveFile => {
if let Some(path) = self.documents[self.active_doc].source_path.clone() {
match self.save_document_to(self.active_doc, &path) {
Ok(()) => self.finish_successful_save(self.active_doc, path),
Err(error) => log::error!("Failed to save: {error}"),
}
} else {
return self.update(Message::SaveFileAs);
}
}
// ── Menu ─────────────────────────────────────
Message::MenuOpen(idx) => {
self.sub_tools_open = false;
if self.active_menu == Some(idx) {
self.active_menu = None;
} else {
self.active_menu = Some(idx);
}
}
Message::MenuClose => {
// If crop tool is active with an active crop, cancel it first
if self.tool_state.active_tool == Tool::Crop
&& self.documents[self.active_doc].crop_state.active
{
self.documents[self.active_doc].crop_state.cancel();
} else if self.documents[self.active_doc]
.selection_transform
.is_some()
{
return self.update(Message::TransformCancel);
} else if self.active_menu.is_some() {
self.active_menu = None;
} else if self.active_dialog != ActiveDialog::None {
self.active_dialog = ActiveDialog::None;
}
}
Message::MenuCommand(crate::panels::menus::MenuCommand::OpenRecent(path_str)) => {
self.active_menu = None;
let path = std::path::PathBuf::from(&path_str);
if path.exists() {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => self.documents[self.active_doc].engine.import_psd(&path_str),
"kra" => self.documents[self.active_doc].engine.import_kra(&path_str),
"hcie" => self.documents[self.active_doc]
.engine
.load_native(&path_str),
_ => self.documents[self.active_doc].engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path.clone());
self.add_recent_file(&path);
let composite = self.documents[self.active_doc]
.engine
.get_composite_pixels();
self.documents[self.active_doc].composite_raw = composite;
self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(
self.documents[self.active_doc].composite_raw.clone(),
);
self.documents[self.active_doc].full_upload.replace(true);
self.documents[self.active_doc].render_generation = self.documents
[self.active_doc]
.render_generation
.wrapping_add(1);
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => log::error!("Failed to open recent file: {}", e),
}
} else {
log::error!("Recent file no longer exists: {}", path_str);
}
}
Message::MenuCommand(crate::panels::menus::MenuCommand::ClearRecent) => {
self.active_menu = None;
self.recent_files.clear();
save_recent_files(&self.recent_files);
}
Message::MenuCommand(command) => {
use crate::panels::menus::MenuCommand;
self.active_menu = None;
match command {
MenuCommand::NewDocument => {
return Task::perform(async {}, |_| {
Message::DialogOpen(ActiveDialog::NewImage)
})
}
MenuCommand::OpenFile => {
return Task::perform(async {}, |_| Message::OpenFileRfd)
}
MenuCommand::Save => return Task::perform(async {}, |_| Message::SaveFile),
MenuCommand::SaveAs => return Task::perform(async {}, |_| Message::SaveFileAs),
MenuCommand::Undo => return Task::perform(async {}, |_| Message::Undo),
MenuCommand::Redo => return Task::perform(async {}, |_| Message::Redo),
MenuCommand::Copy => return Task::perform(async {}, |_| Message::CopyImage),
MenuCommand::Cut => return Task::perform(async {}, |_| Message::CutImage),
MenuCommand::ClearPixels => {
return Task::perform(async {}, |_| Message::ClearPixels)
}
MenuCommand::Paste => return Task::perform(async {}, |_| Message::PasteImage),
MenuCommand::CanvasSize => {
let (w, h) = self.documents[self.active_doc].engine.get_canvas_size();
self.dialog_canvas_size_width = w;
self.dialog_canvas_size_height = h;
self.dialog_canvas_size_anchor = (1, 1);
self.active_dialog = ActiveDialog::CanvasSize;
}
MenuCommand::ImageSize => {
let (w, h) = self.documents[self.active_doc].engine.get_canvas_size();
self.dialog_image_size_width = w;
self.dialog_image_size_height = h;
self.dialog_image_size_original_ratio = w as f32 / h as f32;
self.dialog_image_size_constrain = true;
self.active_dialog = ActiveDialog::ImageSize;
}
MenuCommand::AddLayer => return Task::perform(async {}, |_| Message::LayerAdd),
MenuCommand::DeleteLayer => {
let id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc].engine.delete_layer(id);
}
MenuCommand::LayerStyles => {
return Task::perform(async {}, |_| Message::OpenLayerStyleDialog)
}
MenuCommand::MergeDown => {
return Task::perform(async {}, |_| Message::LayerMergeDown)
}
MenuCommand::Flatten => {
return Task::perform(async {}, |_| Message::LayerFlatten)
}
MenuCommand::SelectAll => {
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
}
MenuCommand::Deselect => {
self.documents[self.active_doc].selection_rect = None;
}
MenuCommand::InvertSelection => {
self.documents[self.active_doc].engine.selection_invert();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
MenuCommand::GrowSelection => {
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Grow");
}
MenuCommand::ShrinkSelection => {
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Shrink");
}
MenuCommand::FeatherSelection => {
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Feather");
}
MenuCommand::BorderSelection => {
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Border");
}
MenuCommand::SmoothSelection => {
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Smooth");
}
MenuCommand::SelectionMode(mode) => {
return Task::perform(async move {}, move |_| {
Message::SelectionModeChanged(mode)
})
}
MenuCommand::SaveSelection => {
return Task::perform(async {}, |_| Message::SelectionSave)
}
MenuCommand::LoadSelection => {
return Task::perform(async {}, |_| Message::SelectionLoad)
}
MenuCommand::ToggleQuickMask => {
return Task::perform(async {}, |_| Message::QuickMaskToggle)
}
MenuCommand::ZoomIn => {
return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1))
}
MenuCommand::ZoomOut => {
return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1))
}
MenuCommand::FitArea => {
let doc = &mut self.documents[self.active_doc];
let fit_x = (doc.pane_size.0 - 24.0) / doc.engine.canvas_width() as f32;
let fit_y = (doc.pane_size.1 - 24.0) / doc.engine.canvas_height() as f32;
doc.zoom = fit_x.min(fit_y).clamp(ZOOM_MIN, ZOOM_MAX);
doc.pan_offset = Vector::ZERO;
}
MenuCommand::ActualPixels => {
return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0))
}
MenuCommand::SelectCropTool => {
return Task::perform(async {}, |_| Message::ToolSelected(Tool::Crop))
}
MenuCommand::GaussianBlur => {
return Task::perform(async {}, |_| {
Message::FilterSelect(hcie_engine_api::FilterType::GaussianBlur)
})
}
MenuCommand::Mosaic => {
return Task::perform(async {}, |_| {
Message::FilterSelect(hcie_engine_api::FilterType::Mosaic)
})
}
MenuCommand::UnsharpMask => {
return Task::perform(async {}, |_| {
Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask)
})
}
MenuCommand::TogglePane(pane) => self.dock.toggle_pane(pane),
MenuCommand::SetTheme(theme) => {
self.theme_state.set_preset(theme);
self.settings.theme_preset = theme;
let _ = self.settings.save();
}
MenuCommand::OpenRecent(_) | MenuCommand::ClearRecent => {
unreachable!("handled before general menu dispatch")
}
}
}
// ── Window controls ──────────────────────────
Message::WindowDrag => {
if let Some(id) = self.window_id {
return iced::window::drag(id);
}
}
Message::WindowMinimize => {
if let Some(id) = self.window_id {
return iced::window::minimize(id, true);
}
}
Message::WindowMaximize => {
if let Some(id) = self.window_id {
return iced::window::toggle_maximize(id);
}
}
Message::WindowClose => {
self.pending_document_close = None;
if let Some(document_index) =
self.documents.iter().position(|document| document.modified)
{
self.active_doc = document_index;
self.active_dialog = ActiveDialog::CloseConfirm;
} else if let Some(id) = self.window_id {
return iced::window::close(id);
}
}
Message::WindowId(id) => {
self.window_id = Some(id);
if self.screenshot_request.is_some() {
return Task::perform(
async {
std::thread::sleep(std::time::Duration::from_millis(750));
},
|_| Message::ScreenshotCapture,
);
}
}
Message::ScreenshotCapture => {
if self.screenshot_request.is_none() {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs());
self.screenshot_request = Some(crate::cli::ScreenshotRequest {
panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: false,
output: std::path::PathBuf::from(format!(
"target/screenshots/iced_{timestamp}.png"
)),
});
}
if let Some(id) = self.window_id {
return iced::window::screenshot(id).map(Message::ScreenshotReady);
}
log::error!("cannot capture screenshot before the window is initialized");
}
Message::ScreenshotReady(screenshot) => {
if let Some(request) = self.screenshot_request.take() {
let captured = if let Some(panel) = request.panel.as_deref() {
crate::screenshot::crop_panel(
&screenshot,
&self.dock,
panel,
self.sidebar_expanded,
)
} else {
Ok(screenshot)
};
if let Err(error) = captured.and_then(|captured| {
crate::screenshot::save_png(&captured, &request.output)
}) {
log::error!("{error}");
} else {
log::info!("screenshot saved to {}", request.output.display());
}
if let Some(id) = self.window_id {
return iced::window::close(id);
}
}
}
Message::ThemeChanged(preset) => {
self.theme_state.set_preset(preset);
self.settings.theme_preset = preset;
let _ = self.settings.save();
}
// ── Image Viewer ─────────────────────────────────
Message::ViewerToggle => {
self.viewer_active = !self.viewer_active;
if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() {
// Initialize to home directory on first open
let start_dir =
dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
self.viewer_state = crate::viewer::ViewerState::new(start_dir);
}
if self.viewer_active {
self.viewer_state.load_preview();
}
}
Message::ViewerSetMode(mode) => {
self.viewer_state.view_mode = mode;
self.viewer_state.load_preview();
}
Message::ViewerNavigate(path) => {
self.viewer_state.set_dir(path);
self.viewer_state.load_preview();
}
Message::ViewerSelectFile(path) => {
// Select the file in the grid
if let Some(idx) = self.viewer_state.images.iter().position(|p| p == &path) {
self.viewer_state.active_image_idx = idx;
self.viewer_state.load_preview();
self.viewer_state.view_mode = crate::viewer::ViewerMode::Viewer;
}
}
Message::ViewerOpenFile(path) => {
// Open into a new editor document so browsing never overwrites a dirty canvas.
let _ = self.update(Message::NewDocument(1, 1));
let document_index = self.documents.len() - 1;
let path_str = path.to_string_lossy().to_string();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => self.documents[document_index].engine.import_psd(&path_str),
"kra" => self.documents[document_index].engine.import_kra(&path_str),
"hcie" => self.documents[document_index].engine.load_native(&path_str),
_ => self.documents[document_index].engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.viewer_active = false;
self.active_doc = document_index;
self.documents[document_index].engine.pre_tile_all_layers();
self.documents[document_index].name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[document_index].source_path = Some(path.clone());
self.add_recent_file(&path);
let composite =
self.documents[document_index].engine.get_composite_pixels();
self.documents[document_index].composite_raw = composite;
self.documents[document_index].composite_pixels = std::sync::Arc::new(
self.documents[document_index].composite_raw.clone(),
);
self.documents[document_index].full_upload.replace(true);
self.documents[document_index].render_generation = self.documents
[document_index]
.render_generation
.wrapping_add(1);
self.documents[document_index].cached_layers =
self.documents[document_index].engine.layer_infos();
let history_len = self.documents[document_index].engine.history_len();
self.documents[document_index].cached_history = (0..history_len)
.filter_map(|i| {
self.documents[document_index]
.engine
.history_description(i)
.map(|d| (i, d))
})
.collect();
self.documents[document_index].history_current =
self.documents[document_index].engine.history_current();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => {
self.documents.remove(document_index);
log::error!("Failed to open file from viewer: {}", e);
self.viewer_state.load_error =
Some(format!("Could not edit {}: {e}", path.display()));
}
}
}
Message::ViewerRefresh => {
self.viewer_state.clear_caches();
self.viewer_state.load_preview();
}
Message::ViewerFullscreen(fs) => {
self.viewer_state.fullscreen = fs;
if let Some(id) = self.window_id {
let mode = if fs {
iced::window::Mode::Fullscreen
} else {
iced::window::Mode::Windowed
};
return iced::window::change_mode(id, mode);
}
}
Message::ViewerPrev => {
if self.viewer_active {
self.viewer_state.prev_image();
self.viewer_state.load_preview();
}
}
Message::ViewerNext => {
if self.viewer_active {
self.viewer_state.next_image();
self.viewer_state.load_preview();
}
}
Message::ViewerEnter => {
if self.viewer_active && !self.viewer_state.images.is_empty() {
let path = self.viewer_state.images[self.viewer_state.active_image_idx].clone();
return Task::perform(async move {}, move |_| {
Message::ViewerOpenFile(path.clone())
});
}
}
Message::SidebarToggleExpanded => {
self.sidebar_expanded = !self.sidebar_expanded;
self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded;
self.settings.panel_layout.sidebar_width =
if self.sidebar_expanded { 72.0 } else { 36.0 };
let grid_size = crate::dock::preview::DockGeometry {
viewport: iced::Size::new(
self.settings.window.width,
self.settings.window.height,
),
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
}
.grid_bounds()
.size();
self.dock.set_grid_size(grid_size);
let _ = self.settings.save();
}
Message::NoOp => {}
Message::TabletTouch { x, y, pressed } => {
// Forward touch/pen position to the shared tablet state.
// iced 0.13 touch events carry no pressure, so this only updates
// position/proximity; a finger lift resets pressure to the mouse
// default (1.0) so the next mouse stroke is full-pressure.
if let Ok(mut ts) = self.tablet_state.lock() {
if pressed {
ts.set_screen_pos(x, y);
} else {
ts.reset_pressure();
}
}
if !pressed {
return Task::perform(async {}, |_| Message::DockReleaseCleanup);
}
}
// ── Contextual key actions ───────────────────────
Message::EscapePressed => {
if self.dock.active_auto_hide.take().is_some() {
return Task::none();
}
// Mirrors egui's Escape handling. Priority: viewer > crop >
// transform > vector selection > selection > dialog > viewer fallback.
match overlay_escape_target(self.sub_tools_open, self.active_menu.is_some()) {
Some(OverlayEscapeTarget::Subtools) => {
self.sub_tools_open = false;
return Task::none();
}
Some(OverlayEscapeTarget::Menu) => {
self.active_menu = None;
return Task::none();
}
None => {}
}
if self.viewer_active {
return self.update(Message::ViewerToggle);
} else if self.sub_tools_open || self.active_menu.is_some() {
return Task::none();
} else if self.documents[self.active_doc].crop_state.active {
return self.update(Message::CropCancel);
} else if self.documents[self.active_doc]
.selection_transform
.is_some()
{
return self.update(Message::TransformCancel);
} else if self.documents[self.active_doc]
.selected_vector_shape
.is_some()
{
return self.update(Message::VectorShapeCancel);
} else if self.documents[self.active_doc].selection_bounds.is_some()
|| self.documents[self.active_doc].selection_rect.is_some()
{
return self.update(Message::Deselect);
} else if self.active_dialog != ActiveDialog::None {
return self.update(Message::DialogClose);
} else {
return self.update(Message::ViewerToggle);
}
}
Message::EnterPressed => {
// Mirrors egui's Enter: crop > transform > text-commit > vector-apply > viewer.
if self.documents[self.active_doc].crop_state.active {
return self.update(Message::CropConfirm);
} else if self.documents[self.active_doc]
.selection_transform
.is_some()
{
return self.update(Message::TransformApply);
} else if self.active_document_mut().text_draft.is_some() {
return self.update(Message::TextCommit);
} else if self.documents[self.active_doc]
.selected_vector_shape
.is_some()
{
return self.update(Message::VectorShapeApply);
} else {
return self.update(Message::ViewerEnter);
}
}
Message::ModifiersChanged(modifiers) => {
self.modifiers = modifiers;
}
Message::SpacePanChanged(held) => {
self.tool_state.space_pan = held;
}
}
// Persist colors to disk when they have changed (debounced via the
// colors_dirty flag so we only write once per change-set, not per frame).
if self.colors_dirty {
self.colors_dirty = false;
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
}
// Lazy-load thumbnails when viewer is active (max LAZY_LOAD_BUDGET per frame)
if self.viewer_active {
let batch = crate::viewer::thumbnail_grid::next_load_batch(&self.viewer_state);
for path in batch {
self.viewer_state.load_thumbnail(&path);
}
}
Task::none()
}
// ── View ────────────────────────────────────────────
/// Build the UI element tree.
///
/// Uses the dock system for the main layout. The title bar, menu bar,
/// dock grid, and status bar surround the content. Dialogs overlay everything.
pub fn view(&self) -> Element<'_, Message> {
let view_start = std::time::Instant::now();
let colors = self.theme_state.colors();
// When the viewer is active, show the full-screen image viewer
if self.viewer_active {
let viewer_panel = crate::viewer::view(&self.viewer_state, colors);
return container(viewer_panel)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
..Default::default()
})
.into();
}
let doc = &self.documents[self.active_doc];
// Title bar (now includes menu items and window controls)
let title_bar = panels::title_bar::view(
&doc.name,
doc.modified,
doc.zoom,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
colors,
self.active_menu,
);
// Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge
log::info!("[view] sidebar_expanded={}", self.sidebar_expanded);
let toolbox = crate::sidebar::view(
&self.tool_state,
&self.fg_color,
&self.bg_color,
colors,
self.active_tool_slot,
self.sub_tools_open,
&self.slot_last_used,
self.sidebar_expanded,
);
// Merged toolbar: icon buttons + tool options in one row
let toolbar = panels::toolbar::view(&self.tool_state, &self.settings, colors);
// Dock grid (without toolbox)
let dock_content = crate::dock::view::dock_view(&self.dock, self);
// Main area: toolbox + dock side by side.
let main_area = iced::widget::row![toolbox, dock_content]
.width(Length::Fill)
.height(Length::Fill)
.spacing(2);
// Status bar
let status_bar = panels::status_bar::view(
&self.tool_state.active_tool,
self.canvas_cursor_pos,
doc.zoom,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
colors,
);
let content = column![title_bar, toolbar, main_area, status_bar,]
.width(Length::Fill)
.height(Length::Fill);
let content = container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
..Default::default()
});
// Dialog overlay
let dialog_overlay: Element<'_, Message> = match &self.active_dialog {
ActiveDialog::None => text("").into(),
ActiveDialog::NewImage => dialogs::new_image::view(
&self.dialog_new_name,
self.dialog_new_width,
self.dialog_new_height,
self.dialog_new_transparent,
colors,
),
ActiveDialog::BrightnessContrast => dialogs::adjustments::brightness_contrast_view(
self.dialog_brightness,
self.dialog_contrast,
colors,
),
ActiveDialog::HueSaturation => dialogs::adjustments::hsl_view(
self.dialog_hue,
self.dialog_saturation,
self.dialog_lightness,
colors,
),
ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name, colors),
ActiveDialog::SelectionOp(op) => dialogs::confirm::selection_op_view(
op,
self.dialog_selection_value,
1.0,
100.0,
colors,
),
ActiveDialog::LayerStyleDialog => {
let doc = &self.documents[self.active_doc];
let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id());
let styles: &'static [hcie_engine_api::LayerStyle] =
Box::leak(styles.into_boxed_slice());
crate::panels::layer_styles::view(
styles,
self.selected_style,
colors,
self.show_style_color_picker,
self.style_color_picker_idx,
self.style_color_hsl,
self.layer_style_offset,
)
}
ActiveDialog::ImageSize => dialogs::image_size::view(
self.dialog_image_size_width,
self.dialog_image_size_height,
self.dialog_image_size_constrain,
colors,
),
ActiveDialog::CanvasSize => dialogs::canvas_size::view(
self.dialog_canvas_size_width,
self.dialog_canvas_size_height,
self.dialog_canvas_size_anchor.0,
self.dialog_canvas_size_anchor.1,
colors,
),
ActiveDialog::About => dialogs::about::view(colors),
};
// Stack main content with overlays (dialog + menu dropdown + dock profile dialog).
// `content` is already wrapped in a styled container with the app
// background, so no extra wrapper is needed.
let has_dialog = self.active_dialog != ActiveDialog::None;
let has_menu = self.active_menu.is_some();
let has_dock_profile_dialog = self.show_dock_profile_dialog;
let has_context_menu = self.canvas_context_menu.is_some();
let has_color_picker = self.color_picker_target.is_some();
let has_floating = !self.dock.floating.is_empty();
if has_dialog
|| has_menu
|| has_dock_profile_dialog
|| has_context_menu
|| has_color_picker
|| has_floating
{
let mut stack = iced::widget::Stack::new().push(content);
for item in &self.dock.floating {
stack = stack.push(crate::dock::floating::card(item, self, colors));
}
if has_dialog {
stack = stack.push(dialog_overlay);
}
if has_dock_profile_dialog {
stack = stack.push(dialogs::dock_profile::view(
&self.settings.dock_profiles,
self.active_dock_profile.as_deref(),
&self.dock_profile_new_name,
colors,
));
}
if let Some(menu_overlay) = panels::menus::dropdown_overlay(
self.active_menu,
&self.recent_files,
self.theme_state.colors(),
Some(&self.dock),
self.settings.window.width,
) {
stack = stack.push(menu_overlay);
}
if let Some((cx, cy)) = self.canvas_context_menu {
stack = stack.push(panels::menus::canvas_context_menu(
cx,
cy,
&self.documents[self.active_doc],
self.theme_state.colors(),
));
}
if has_color_picker {
stack = stack.push(crate::color_picker::popup_view(
self.popup_color,
&self.popup_color_hex,
self.popup_color_tab,
colors,
));
}
let _elapsed = view_start.elapsed();
// Performance log measuring Elm view reconstruction time.
// useful to track widget layout allocation and view model reconstruction times.
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
stack.width(Length::Fill).height(Length::Fill).into()
} else {
let _elapsed = view_start.elapsed();
// Performance log measuring Elm view reconstruction time.
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
content.into()
}
}
// ── Theme & Subscription ────────────────────────────
/// Return the application theme.
pub fn theme(&self) -> Theme {
if self.theme_state.colors().is_light {
Theme::Light
} else {
Theme::Dark
}
}
/// Subscriptions — keyboard events for shortcuts.
///
/// Handles all keyboard shortcuts matching the egui GUI:
/// - Ctrl+Z: Undo
/// - Ctrl+Y / Ctrl+Shift+Z: Redo
/// - Ctrl+S: Save
/// - Ctrl+Shift+S: Save As
/// - Ctrl+N: New
/// - Ctrl+O: Open
/// - Ctrl+C: Copy
/// - Ctrl+V: Paste
/// - Ctrl+A: Select All
/// - Ctrl+D: Deselect
/// - Ctrl+Shift+I: Inverse Selection
/// - Ctrl++: Zoom In
/// - Ctrl+-: Zoom Out
/// - Ctrl+0: Fit to Screen
/// - Ctrl+1: 100% Zoom
/// - Delete: Clear selection
/// - F11: Fullscreen toggle
/// - B: Brush, E: Eraser, V: Move, M: Marquee, L: Lasso
/// - W: Magic Wand, T: Text, U: Shape (Vector Rectangle)
/// - Escape: Close dialog / exit viewer
/// - Enter: Confirm / open in viewer
pub fn subscription(&self) -> iced::Subscription<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 } => {
Some(Message::DockPointerMoved(position.x, position.y))
}
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
Some(Message::DockPointerReleased)
}
_ => None,
},
iced::Event::Window(iced::window::Event::Opened { size, .. })
| iced::Event::Window(iced::window::Event::Resized(size)) => {
Some(Message::DockViewportChanged(size))
}
iced::Event::Touch(touch_event) => {
// iced 0.13 touch events do not carry a force/pressure field,
// so we can only forward position. A touch press/move marks
// the tablet as in-proximity; lifting resets pressure to the
// mouse default (1.0) so subsequent mouse strokes behave.
match touch_event {
iced::touch::Event::FingerPressed { position, .. }
| iced::touch::Event::FingerMoved { position, .. } => {
Some(Message::TabletTouch {
x: position.x,
y: position.y,
pressed: true,
})
}
iced::touch::Event::FingerLifted { .. }
| iced::touch::Event::FingerLost { .. } => Some(Message::TabletTouch {
x: 0.0,
y: 0.0,
pressed: false,
}),
}
}
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) => {
Some(Message::ModifiersChanged(modifiers))
}
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Space),
..
}) => Some(Message::SpacePanChanged(true)),
iced::Event::Keyboard(iced::keyboard::Event::KeyReleased {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Space),
..
}) => Some(Message::SpacePanChanged(false)),
_ => None,
}
});
// The marching ants are rendered in the persistent GPU canvas shader.
// Use a real timer rather than an immediately-ready async loop: the old
// loop generated an unbounded stream of CompositeRefresh messages and
// starved pointer events while drawing.
let marching_ants = if self
.documents
.iter()
.any(|doc| doc.selection_bounds.is_some())
&& !self.tool_state.is_drawing
{
iced::time::every(std::time::Duration::from_millis(50))
.map(|_| Message::CompositeRefresh)
} else {
iced::Subscription::none()
};
iced::Subscription::batch(vec![keyboard, mouse, marching_ants])
}
}
/// Identifies which transient overlay consumes Escape before editor-level cancellation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OverlayEscapeTarget {
Subtools,
Menu,
}
/// Resolves Escape priority for transient navigation surfaces.
///
/// **Arguments:** `subtools_open` and `menu_open` describe the two transient overlays.
/// **Returns:** The single overlay to close, preferring the more specific subtool popup.
/// **Logic & Workflow:** Popup priority is stable even if inconsistent state briefly exposes both.
/// **Side Effects / Dependencies:** None.
fn overlay_escape_target(subtools_open: bool, menu_open: bool) -> Option<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)
}
}
#[cfg(test)]
mod cycle_one_ux_tests {
use super::{
active_index_after_document_close, document_close_disposition, overlay_escape_target,
DocumentCloseDisposition, OverlayEscapeTarget,
};
/// Confirms Escape dismisses subtools before menus and leaves editor cancellation untouched.
#[test]
fn popup_and_menu_escape_priority_is_stable() {
assert_eq!(
overlay_escape_target(true, true),
Some(OverlayEscapeTarget::Subtools)
);
assert_eq!(
overlay_escape_target(false, true),
Some(OverlayEscapeTarget::Menu)
);
assert_eq!(overlay_escape_target(false, false), None);
}
/// Confirms dirty tabs require confirmation, clean tabs close directly, and the final tab uses
/// the existing whole-window confirmation path.
#[test]
fn document_tab_close_uses_semantic_continuations() {
assert_eq!(
document_close_disposition(3, 1, true),
DocumentCloseDisposition::Confirm
);
assert_eq!(
document_close_disposition(3, 1, false),
DocumentCloseDisposition::Close
);
assert_eq!(
document_close_disposition(1, 0, false),
DocumentCloseDisposition::Welcome
);
assert_eq!(
document_close_disposition(2, 2, false),
DocumentCloseDisposition::Invalid
);
}
/// Confirms closing tabs before, at, and after the active tab never leaves a stale index.
#[test]
fn document_tab_close_keeps_active_index_safe() {
assert_eq!(active_index_after_document_close(2, 0, 2), 1);
assert_eq!(active_index_after_document_close(1, 1, 2), 1);
assert_eq!(active_index_after_document_close(2, 2, 2), 1);
assert_eq!(active_index_after_document_close(0, 2, 2), 0);
}
}
/// Find the topmost text layer whose rasterized pixels contain the point
/// `(x, y)` in canvas coordinates.
fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<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
}