10470 lines
488 KiB
Rust
10470 lines
488 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 document_open;
|
||
mod transform_dirty;
|
||
|
||
use self::document_open::load_path_into_engine;
|
||
use self::transform_dirty::preview_bounds;
|
||
use crate::ai_chat::{self, AiChatState, ChatMessage, ComfyUiState, SYSTEM_PROMPT};
|
||
use crate::dialogs;
|
||
use crate::dock::manager::DockDragState;
|
||
use crate::dock::state::{DockState, PaneType};
|
||
use crate::i18n;
|
||
use crate::io::tablet::TabletState;
|
||
use crate::panels;
|
||
use crate::selection;
|
||
use crate::selection::{CropState, SelectionTransform, TransformHandle};
|
||
use crate::theme::ThemeState;
|
||
use hcie_engine_api::{
|
||
BlendMode, BrushStyle, BrushTip, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN,
|
||
};
|
||
use iced::widget::{column, container, text};
|
||
use iced::{Element, Length, Task, Theme, Vector};
|
||
use std::path::PathBuf;
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
/// Returns the path to the HCIE config directory (~/.config/hcie-iced/).
|
||
/// Creates the directory if it doesn't exist.
|
||
fn config_dir() -> PathBuf {
|
||
let dir = dirs::config_dir()
|
||
.unwrap_or_else(|| PathBuf::from("."))
|
||
.join("hcie-iced");
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
dir
|
||
}
|
||
|
||
/// Returns the path to the recent files JSON config.
|
||
fn recent_files_path() -> PathBuf {
|
||
config_dir().join("recent_files.json")
|
||
}
|
||
|
||
/// Load recent files from disk.
|
||
fn load_recent_files() -> Vec<RecentFileEntry> {
|
||
let path = recent_files_path();
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
|
||
Err(_) => Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Save recent files to disk.
|
||
fn save_recent_files(files: &[RecentFileEntry]) {
|
||
let path = recent_files_path();
|
||
if let Ok(json) = serde_json::to_string_pretty(files) {
|
||
let _ = std::fs::write(&path, json);
|
||
}
|
||
}
|
||
|
||
/// Persisted color state (fg/bg/recent colors), mirroring egui's `hcie_colors`.
|
||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||
struct PersistedColors {
|
||
#[serde(default)]
|
||
fg: Option<[u8; 4]>,
|
||
#[serde(default)]
|
||
bg: Option<[u8; 4]>,
|
||
#[serde(default)]
|
||
recent: Vec<[u8; 4]>,
|
||
}
|
||
|
||
/// Path to the persisted colors JSON (~/.config/hcie-iced/colors.json).
|
||
fn colors_path() -> PathBuf {
|
||
config_dir().join("colors.json")
|
||
}
|
||
|
||
/// Load persisted colors from disk (or defaults if missing).
|
||
///
|
||
/// Logs a warning if the file exists but cannot be parsed so corrupted JSON
|
||
/// does not silently revert colors to defaults.
|
||
fn load_colors() -> PersistedColors {
|
||
let path = colors_path();
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(json) => serde_json::from_str(&json)
|
||
.map_err(|e| {
|
||
log::warn!(
|
||
"Failed to parse persisted colors at {}: {}. Using defaults.",
|
||
path.display(),
|
||
e
|
||
);
|
||
e
|
||
})
|
||
.unwrap_or_default(),
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PersistedColors::default(),
|
||
Err(e) => {
|
||
log::warn!(
|
||
"Failed to read persisted colors at {}: {}",
|
||
path.display(),
|
||
e
|
||
);
|
||
PersistedColors::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Save colors to disk.
|
||
fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) {
|
||
let path = colors_path();
|
||
if let Some(parent) = path.parent() {
|
||
let _ = std::fs::create_dir_all(parent);
|
||
}
|
||
let data = PersistedColors {
|
||
fg: Some(fg),
|
||
bg: Some(bg),
|
||
recent: recent.to_vec(),
|
||
};
|
||
if let Ok(json) = serde_json::to_string_pretty(&data) {
|
||
let _ = std::fs::write(&path, json);
|
||
}
|
||
}
|
||
|
||
/// Active dialog type.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
#[allow(dead_code)]
|
||
pub enum ActiveDialog {
|
||
None,
|
||
NewImage,
|
||
BrightnessContrast,
|
||
HueSaturation,
|
||
CloseConfirm,
|
||
SelectionOp(&'static str),
|
||
LayerStyleDialog,
|
||
ImageSize,
|
||
CanvasSize,
|
||
About,
|
||
/// Save/export error with alternative path suggestion.
|
||
SaveError,
|
||
/// Clipboard paste exceeds canvas — confirm canvas expansion.
|
||
ExpandCanvas {
|
||
paste_w: u32,
|
||
paste_h: u32,
|
||
canvas_w: u32,
|
||
canvas_h: u32,
|
||
},
|
||
/// SVG vector node editor.
|
||
SvgEditor,
|
||
}
|
||
|
||
/// Top-level application state.
|
||
pub struct HcieIcedApp {
|
||
/// All open documents.
|
||
pub documents: Vec<IcedDocument>,
|
||
/// Whether the center document host shows the post-close welcome surface.
|
||
pub show_welcome: bool,
|
||
/// Index of the currently active document.
|
||
pub active_doc: usize,
|
||
/// Current tool state.
|
||
pub tool_state: ToolState,
|
||
/// Foreground drawing color (RGBA).
|
||
pub fg_color: [u8; 4],
|
||
/// Background drawing color (RGBA).
|
||
pub bg_color: [u8; 4],
|
||
/// Last known cursor position from mouse_area events.
|
||
pub last_cursor_pos: Option<(f32, f32)>,
|
||
/// Cursor position in canvas-space for status bar display.
|
||
pub canvas_cursor_pos: Option<(u32, u32)>,
|
||
/// Currently active dialog (if any).
|
||
pub active_dialog: ActiveDialog,
|
||
/// Index of the active tool slot in the sidebar (for sub-tools).
|
||
pub active_tool_slot: usize,
|
||
/// Slot whose expanded sub-tool chooser remains open until toggled again.
|
||
pub open_subtool_slot: Option<usize>,
|
||
/// Per-slot last-used sub-tool index (egui's `slot_last_used`). The sidebar
|
||
/// shows the last-used tool's icon/label for each slot rather than always
|
||
/// the primary tool.
|
||
pub slot_last_used: Vec<usize>,
|
||
/// Persistent settings (tool settings, panel layout, window).
|
||
pub settings: crate::settings::AppSettings,
|
||
/// Window ID for window control operations (drag, minimize, maximize, close).
|
||
pub window_id: Option<iced::window::Id>,
|
||
/// Pending automated or keyboard-triggered full-viewport screenshot.
|
||
pub screenshot_request: Option<crate::cli::ScreenshotRequest>,
|
||
/// New Image dialog state.
|
||
pub _dialog_input: String,
|
||
pub _dialog_input2: String,
|
||
pub _show_performance: bool,
|
||
pub _ui_scale: f32,
|
||
pub modifiers: iced::keyboard::Modifiers,
|
||
pub dialog_new_name: String,
|
||
pub dialog_new_width: u32,
|
||
pub dialog_new_height: u32,
|
||
pub dialog_new_transparent: bool,
|
||
/// Adjustment dialog state.
|
||
pub dialog_brightness: f32,
|
||
pub dialog_contrast: f32,
|
||
pub dialog_hue: f32,
|
||
pub dialog_saturation: f32,
|
||
pub dialog_lightness: f32,
|
||
/// Selection operation dialog state.
|
||
pub dialog_selection_value: f32,
|
||
/// Image Size dialog state.
|
||
pub dialog_image_size_width: u32,
|
||
pub dialog_image_size_height: u32,
|
||
pub dialog_image_size_constrain: bool,
|
||
pub dialog_image_size_original_ratio: f32,
|
||
/// Canvas Size dialog state.
|
||
pub dialog_canvas_size_width: u32,
|
||
pub dialog_canvas_size_height: u32,
|
||
pub dialog_canvas_size_anchor: (usize, usize),
|
||
/// Selected filter.
|
||
pub selected_filter: Option<FilterType>,
|
||
/// Filter parameters (JSON) for the currently selected filter.
|
||
pub filter_params: serde_json::Value,
|
||
/// Filter preview state.
|
||
pub filter_preview_active: bool,
|
||
/// Which filter categories are expanded (name → expanded).
|
||
pub filter_categories_expanded: std::collections::HashMap<String, bool>,
|
||
/// Immutable active-layer pixels used by filter and adjustment previews.
|
||
preview_baseline: Option<PreviewBaseline>,
|
||
/// Original styles restored when the Layer Style dialog is cancelled.
|
||
layer_style_baseline: Option<LayerStyleBaseline>,
|
||
/// File operation associated with the currently open native file dialog.
|
||
pending_file_operation: Option<PendingFileOperation>,
|
||
/// Document tab awaiting Save/Discard confirmation before removal.
|
||
pending_document_close: Option<usize>,
|
||
/// Dock layout state.
|
||
pub dock: DockState,
|
||
/// Shared tablet state for pressure input.
|
||
pub tablet_state: Arc<Mutex<TabletState>>,
|
||
/// Whether the app has been initialized (load_path processed).
|
||
pub initialized: bool,
|
||
/// Theme state for consistent styling.
|
||
pub theme_state: ThemeState,
|
||
/// Currently open menu index (None = no menu open).
|
||
pub active_menu: Option<usize>,
|
||
/// Recently opened files (most recent first, max 20).
|
||
pub recent_files: Vec<RecentFileEntry>,
|
||
/// Recent colors used (most recent first, max 40).
|
||
pub recent_colors: Vec<[u8; 4]>,
|
||
/// Target currently edited by the shared popup color selector.
|
||
pub color_picker_target: Option<crate::color_picker::ColorPickerTarget>,
|
||
/// Live RGBA value displayed by the shared popup color selector.
|
||
pub popup_color: [u8; 4],
|
||
/// Editable hexadecimal value retained while the shared selector is open.
|
||
pub popup_color_hex: String,
|
||
/// Shared selector tab: RGB, wheel, or HSL.
|
||
pub popup_color_tab: usize,
|
||
/// Selected style index in Layer Styles panel.
|
||
pub selected_style: usize,
|
||
/// Whether the color picker popup is open for a layer style.
|
||
pub show_style_color_picker: bool,
|
||
/// Index of the style whose color picker is open.
|
||
pub style_color_picker_idx: usize,
|
||
/// HSL color state for the style color picker (hue 0..360, sat 0..1, light 0..1).
|
||
pub style_color_hsl: (f32, f32, f32),
|
||
/// True when the color picker is editing the shadow color (BevelEmboss),
|
||
/// false when editing the highlight color or the single-color style's color.
|
||
pub style_color_is_shadow: bool,
|
||
/// Currently active color picker tab (0=Wheel, 1=Sliders, 2=Grid).
|
||
pub color_tab: usize,
|
||
/// Layer Style dialog drag offset from center (x, y) in pixels.
|
||
pub layer_style_offset: (f32, f32),
|
||
/// Whether the Layer Style dialog is being dragged.
|
||
pub layer_style_dragging: bool,
|
||
/// Last drag position for delta calculation.
|
||
pub layer_style_drag_start: Option<(f32, f32)>,
|
||
/// Marching ants animation offset (cycles 0.0..1.0 for dashed line animation).
|
||
pub marching_ants_offset: f32,
|
||
/// AI chat state (messages, config, streaming).
|
||
pub ai_chat_state: AiChatState,
|
||
/// Which sub-tab is active in the AI Assistant panel (0 = ComfyUI, 1 = AI Chat).
|
||
pub ai_assistant_tab: usize,
|
||
/// ComfyUI connection and pipeline state.
|
||
pub comfy_state: ComfyUiState,
|
||
/// Title bar search text.
|
||
pub title_search: String,
|
||
/// Whether the panel list popup is shown.
|
||
pub show_panel_list: bool,
|
||
/// Abort handle for the currently active AI response stream.
|
||
ai_chat_abort: Option<iced::task::Handle>,
|
||
/// Script editor text content (raw string for error display/reference).
|
||
pub script_text: String,
|
||
/// Script editor content for the Iced text_editor widget.
|
||
pub script_content: iced::widget::text_editor::Content,
|
||
/// Last script parse/execution error message.
|
||
pub script_error: Option<String>,
|
||
/// Line number (1-indexed) of the last error, for highlighting.
|
||
pub script_error_line: Option<usize>,
|
||
/// Whether a script is currently executing.
|
||
pub script_is_running: bool,
|
||
/// AI script generator — natural language prompt input.
|
||
pub ai_script_prompt: String,
|
||
/// AI script generator — generated DSL script output.
|
||
pub ai_script_output: String,
|
||
/// AI script generator — last error message (if any).
|
||
pub ai_script_error: Option<String>,
|
||
/// AI script generator — last successful generation/copy/execution notice.
|
||
pub ai_script_notice: Option<String>,
|
||
/// AI script generator — whether a generation request is in progress.
|
||
pub ai_script_is_running: bool,
|
||
/// AI script generator — selected provider type ("ollama", "claude", "openai").
|
||
pub ai_script_provider: String,
|
||
/// AI script generator — model name for the selected provider.
|
||
pub ai_script_model: String,
|
||
/// AI script generator — base URL for the selected provider.
|
||
pub ai_script_url: String,
|
||
/// Whether the FastStone-like image viewer is active.
|
||
pub viewer_active: bool,
|
||
/// Viewer state for directory browsing and thumbnail grid.
|
||
pub viewer_state: crate::viewer::ViewerState,
|
||
/// Name of the currently active dock profile (if any).
|
||
pub active_dock_profile: Option<String>,
|
||
/// Text input for new dock profile name.
|
||
pub dock_profile_new_name: String,
|
||
/// Index of the dock profile being renamed (if any).
|
||
pub dock_profile_rename_idx: Option<usize>,
|
||
/// Text input for renaming a dock profile.
|
||
pub dock_profile_rename_buf: String,
|
||
/// Whether the dock profile dialog is visible.
|
||
pub show_dock_profile_dialog: bool,
|
||
/// Current UI language for internationalization.
|
||
pub _language: i18n::Language,
|
||
/// Active Brushes & Tips category filter retained while the panel redraws.
|
||
pub brush_category: crate::panels::brushes::BrushCategory,
|
||
/// Context menu screen position (x, y). None means closed.
|
||
pub canvas_context_menu: Option<(f32, f32)>,
|
||
/// Runtime build version string (e.g. `"0.1.0+build.992"`), incremented on each launch.
|
||
pub runtime_version: String,
|
||
/// Dirty flag set whenever fg/bg/recent colors change, so colors can be
|
||
/// persisted to disk (egui persists these under the `hcie_colors` key).
|
||
pub colors_dirty: bool,
|
||
/// True while the user is dragging on the color wheel; shape sync and
|
||
/// composite refresh are deferred until the drag ends to avoid per-move lag.
|
||
pub color_dragging: bool,
|
||
/// Final color sampled during the active wheel drag; committed to Recent on mouse-up.
|
||
pub pending_drag_color: Option<[u8; 4]>,
|
||
/// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration).
|
||
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
|
||
/// Cached `(kind, svg)` pairs from the engine's shape catalog for the active document.
|
||
/// Updated when the Custom Shapes panel is refreshed so `VectorDrawEnd` always uses the
|
||
/// latest catalog contents without re-scanning the shapes directory on every drag.
|
||
pub cached_custom_shapes: Vec<(String, String)>,
|
||
/// Last drag position during vector shape editing (canvas coordinates).
|
||
pub vector_drag_last: Option<(f32, f32)>,
|
||
/// Last composite bounds during drag — used to skip redundant re-renders.
|
||
pub vector_drag_last_bounds: Option<(f32, f32, f32, f32)>,
|
||
/// Last composite angle during drag — used to skip redundant re-renders.
|
||
pub vector_drag_last_angle: Option<f32>,
|
||
/// Immutable basis for the active vector drag.
|
||
pub vector_drag_session: Option<crate::vector_edit::VectorDragSession>,
|
||
/// Boolean operation shape A selector index (geometry panel).
|
||
pub bool_shape_a: Option<usize>,
|
||
/// Boolean operation shape B selector index (geometry panel).
|
||
pub bool_shape_b: Option<usize>,
|
||
/// Whether the toolbox is expanded to 2 columns (false = single column 36px, true = 2 columns 72px).
|
||
pub sidebar_expanded: bool,
|
||
/// Plugin registry for the plugin system.
|
||
pub plugin_registry: crate::plugins::registry::PluginRegistry,
|
||
/// SVG editor state (None when closed).
|
||
pub svg_editor_state: Option<crate::panels::svg_editor::SvgEditorState>,
|
||
/// Save error state (None when no error).
|
||
pub save_error_state: Option<crate::dialogs::save_error::SaveErrorState>,
|
||
}
|
||
|
||
/// A recently opened file entry.
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||
pub struct RecentFileEntry {
|
||
pub path: String,
|
||
pub name: String,
|
||
}
|
||
|
||
/// Immutable pixels captured before a non-destructive GUI preview begins.
|
||
#[derive(Debug, Clone)]
|
||
struct PreviewBaseline {
|
||
document_index: usize,
|
||
layer_id: u64,
|
||
pixels: Vec<u8>,
|
||
}
|
||
|
||
/// Layer-style state captured when the modal editor opens.
|
||
#[derive(Debug, Clone)]
|
||
struct LayerStyleBaseline {
|
||
document_index: usize,
|
||
layer_id: u64,
|
||
styles: Vec<LayerStyle>,
|
||
was_modified: bool,
|
||
changed: bool,
|
||
}
|
||
|
||
/// Distinguishes open and save callbacks and carries close-after-save continuation.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum PendingFileOperation {
|
||
Open,
|
||
SaveAs { close_after: bool },
|
||
}
|
||
|
||
/// Per-document state wrapping an engine instance.
|
||
pub struct IcedDocument {
|
||
pub engine: Engine,
|
||
/// Stable full RGBA recovery image shared with the persistent shader pipeline.
|
||
/// Normal frames use packed dirty payloads; this is read only for full texture recovery.
|
||
pub composite_pixels: crate::canvas::texture_update::SharedCompositePixels,
|
||
/// Mutable composite buffer for partial copies and packed dirty-update construction.
|
||
pub(crate) composite_raw: Vec<u8>,
|
||
/// Document display name.
|
||
pub name: String,
|
||
/// Original file path, if loaded from disk.
|
||
pub source_path: Option<std::path::PathBuf>,
|
||
/// Whether the document has unsaved changes.
|
||
pub modified: bool,
|
||
/// Per-document zoom level.
|
||
pub zoom: f32,
|
||
/// Per-document pan offset in screen pixels.
|
||
pub pan_offset: Vector,
|
||
/// Cached layer infos for the layers panel.
|
||
pub cached_layers: Vec<hcie_engine_api::LayerInfo>,
|
||
/// Cached history items for the history panel.
|
||
pub cached_history: Vec<(usize, String)>,
|
||
/// Current history index.
|
||
pub history_current: i32,
|
||
/// Selection rectangle in canvas coordinates (for select tool).
|
||
pub selection_rect: Option<(f32, f32, f32, f32)>,
|
||
/// Vector shape being drawn (start pos, current pos).
|
||
pub vector_draw: Option<((f32, f32), (f32, f32))>,
|
||
/// Canvas pane size (width, height) in pixels — updated by the view.
|
||
pub pane_size: (f32, f32),
|
||
/// Render generation counter; incremented whenever the engine composite
|
||
/// buffer is refreshed.
|
||
pub render_generation: u64,
|
||
/// Dirty region from the last engine composite [x0, y0, x1, y1].
|
||
/// Set by `refresh_composite_if_needed()`, consumed by the shader.
|
||
pub dirty_region: std::cell::RefCell<Option<[u32; 4]>>,
|
||
/// Whether a full texture upload is needed (first frame, resize, file load).
|
||
/// Set to true after loading a file or changing canvas dimensions.
|
||
pub full_upload: std::cell::RefCell<bool>,
|
||
/// Active selection transform (floating pixels after cut/copy-paste)
|
||
pub selection_transform: Option<SelectionTransform>,
|
||
/// Transform handle being dragged
|
||
pub transform_drag_handle: TransformHandle,
|
||
/// Transform drag start position in canvas coordinates
|
||
pub transform_drag_start: Option<(f32, f32)>,
|
||
/// Original transform state before drag (for undo)
|
||
pub transform_original: Option<SelectionTransform>,
|
||
/// Source pixels and placement removed when a move transform began, for exact Cancel restore.
|
||
pub transform_source: Option<SelectionTransform>,
|
||
/// Original active-layer pixels for the transform's single history transaction.
|
||
pub transform_layer_baseline: Option<Vec<u8>>,
|
||
/// Original engine selection mask restored when a transform is cancelled.
|
||
pub transform_selection_baseline: Option<Vec<u8>>,
|
||
/// Composite snapshot without the floating transform, used as the base for each preview frame.
|
||
pub transform_preview_base: Option<Vec<u8>>,
|
||
/// Internal clipboard for copy/paste operations
|
||
pub internal_clipboard: Option<SelectionTransform>,
|
||
/// Stashed paste pixels when the paste exceeds the canvas, awaiting
|
||
/// the ExpandCanvas dialog decision. Consumed on Expand or Cancel.
|
||
pub pending_paste: Option<(Vec<u8>, u32, u32)>,
|
||
/// When true, the pending_paste pixels should be placed as a new layer
|
||
/// instead of entering transform mode (set by "Paste as New Layer").
|
||
pub pending_paste_as_new_layer: bool,
|
||
/// Raw selection mask retained for transform and selection-history workflows.
|
||
pub selection_mask: Option<Arc<Vec<u8>>>,
|
||
/// Encoded R8 selection texture: zero unselected, 128 interior, 255 border.
|
||
pub selection_texture: Option<Arc<Vec<u8>>>,
|
||
/// Whether a selection engine/local mutation still needs cache synchronization.
|
||
pub selection_model_dirty: bool,
|
||
/// Whether the encoded selection texture has changed since last GPU upload.
|
||
pub selection_mask_dirty: std::cell::Cell<bool>,
|
||
/// Selection bounds (x, y, width, height)
|
||
pub selection_bounds: Option<(u32, u32, u32, u32)>,
|
||
/// History of selection masks for undo support.
|
||
pub selection_history: Vec<Option<Vec<u8>>>,
|
||
/// Engine history index when the selection was last modified.
|
||
pub selection_history_marker: i32,
|
||
/// Crop tool state
|
||
pub crop_state: CropState,
|
||
/// Accumulated lasso points (freehand selection) in canvas coordinates.
|
||
pub lasso_points: Vec<(u32, u32)>,
|
||
/// Accumulated polygon vertices (polygonal lasso) in canvas coordinates.
|
||
pub polygon_points: Vec<(u32, u32)>,
|
||
/// Gradient drag start/current endpoints in canvas coordinates.
|
||
pub gradient_drag: Option<((f32, f32), (f32, f32))>,
|
||
/// VisionSelect bounding-box drag (start, current) in canvas coordinates.
|
||
pub vision_rect: Option<(f32, f32, f32, f32)>,
|
||
/// Draft text state for the on-canvas text tool.
|
||
pub text_draft: Option<TextDraft>,
|
||
/// How newly generated selection masks modify the existing mask.
|
||
pub selection_mode: selection::SelectionMode,
|
||
/// In-memory saved selection for the document's Load/Save Selection commands.
|
||
pub saved_selection_mask: Option<Vec<u8>>,
|
||
/// Whether quick-mask visualization is enabled.
|
||
pub quick_mask: bool,
|
||
/// Index of the currently selected vector shape (None = no selection).
|
||
pub selected_vector_shape: Option<usize>,
|
||
/// Index into the list of shapes at the current click position for cycling
|
||
/// through overlapping vector shapes. Resets to 0 when clicking a new position.
|
||
pub vector_cycle_index: usize,
|
||
/// Which vector edit handle is active (hovered or being dragged).
|
||
pub vector_edit_handle: hcie_engine_api::VectorEditHandle,
|
||
/// Transformed vector shape being previewed during a drag (not yet committed
|
||
/// to the engine). Kept in the document so the overlay canvas can draw it.
|
||
pub vector_drag_preview: Option<hcie_engine_api::VectorShape>,
|
||
/// True while the user is holding Shift during a rotation drag (enables snap visual feedback).
|
||
pub rotation_snap_active: bool,
|
||
/// The snapped angle (radians) currently displayed in the snap indicator overlay.
|
||
pub rotation_snap_angle: f32,
|
||
/// True while the user is holding Shift during a resize drag (enables aspect-lock visual feedback).
|
||
pub resize_snap_active: bool,
|
||
/// Cached layer thumbnails keyed by layer ID — (thumb_w, thumb_h, RGBA pixels).
|
||
/// Regenerated only for dirty layers in `refresh_panel_caches()`.
|
||
pub layer_thumbnails: std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
|
||
/// Layer IDs whose thumbnails must be regenerated after a canvas-only refresh.
|
||
/// Dirty engine flags are cleared after compositing, so this set carries the
|
||
/// invalidation until panel work is safe to perform at stroke completion.
|
||
pub pending_thumbnail_layers: std::collections::HashSet<u64>,
|
||
/// Thumbnail generation generation counter; incremented when any thumbnail
|
||
/// is regenerated so the view can detect stale cache without locking.
|
||
pub thumb_gen: u64,
|
||
}
|
||
|
||
/// In-progress text layer being authored with the Text tool.
|
||
///
|
||
/// Holds the editable content plus font/size/color/alignment/orientation so the
|
||
/// properties panel and canvas overlay can render a live preview before the
|
||
/// text is committed to the engine as a Text layer.
|
||
#[derive(Debug)]
|
||
pub struct TextDraft {
|
||
pub content: String,
|
||
pub font: String,
|
||
pub size: f32,
|
||
pub color: [u8; 4],
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub angle: f32,
|
||
pub alignment: hcie_engine_api::TextAlignment,
|
||
pub orientation: hcie_engine_api::TextOrientation,
|
||
/// Stateful multiline editor content kept in sync with `content`.
|
||
pub editor: iced::widget::text_editor::Content,
|
||
}
|
||
|
||
impl Default for TextDraft {
|
||
fn default() -> Self {
|
||
Self {
|
||
content: String::new(),
|
||
font: "Arial".to_string(),
|
||
size: 24.0,
|
||
color: [0, 0, 0, 255],
|
||
x: 100.0,
|
||
y: 100.0,
|
||
angle: 0.0,
|
||
alignment: hcie_engine_api::TextAlignment::Left,
|
||
orientation: hcie_engine_api::TextOrientation::Horizontal,
|
||
editor: iced::widget::text_editor::Content::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drawing tool state.
|
||
pub struct ToolState {
|
||
/// Currently selected tool.
|
||
pub active_tool: Tool,
|
||
/// Whether the user is currently drawing (mouse button held).
|
||
pub is_drawing: bool,
|
||
/// Canvas-space coordinates of the last pointer event during a stroke.
|
||
pub last_stroke_pos: Option<(f32, f32)>,
|
||
/// Accumulated distance for the current stroke (for pressure interpolation).
|
||
pub brush_accumulated_dist: f32,
|
||
/// Current brush size.
|
||
pub brush_size: f32,
|
||
/// Current brush opacity.
|
||
pub brush_opacity: f32,
|
||
/// Current brush hardness.
|
||
pub brush_hardness: f32,
|
||
/// Current brush style.
|
||
pub brush_style: BrushStyle,
|
||
/// Current brush spacing.
|
||
pub brush_spacing: f32,
|
||
/// True when each brush dab should vary its color slightly.
|
||
pub brush_color_variant: bool,
|
||
/// Magnitude of the per-dab color variation (0.0..1.0).
|
||
pub brush_variant_amount: f32,
|
||
/// Star brush base rotation angle in radians.
|
||
pub brush_angle: f32,
|
||
/// Star brush per-dab size variance (0.0..1.0).
|
||
pub brush_size_variance: f32,
|
||
/// Star brush per-dab angle variance (0.0..1.0).
|
||
pub brush_angle_variance: f32,
|
||
/// True when the brush color should cycle through the hue over stroke distance.
|
||
pub brush_cyclic_color: bool,
|
||
/// Speed of the hue cycle along the stroke (0.01..5.0).
|
||
pub brush_cyclic_speed: f32,
|
||
/// Timestamp of the last update() call for frame timing.
|
||
pub last_update_instant: std::time::Instant,
|
||
/// Imported brush presets from ABR/KPP files.
|
||
pub imported_brushes: Vec<hcie_engine_api::BrushPreset>,
|
||
/// Complete preset currently supplying non-style brush-tip parameters.
|
||
pub active_brush_preset: Option<hcie_engine_api::BrushPreset>,
|
||
/// True if currently holding shift to resize brush
|
||
pub is_resizing_brush: bool,
|
||
/// Starting brush size before resize drag
|
||
pub brush_resize_start_size: f32,
|
||
/// Starting pointer position for brush resize drag
|
||
pub brush_resize_start_pos: Option<(f32, f32)>,
|
||
/// Last click time for double-click detection
|
||
pub last_click_time: std::time::Instant,
|
||
/// Last click pos for double-click detection
|
||
pub last_click_pos: Option<(f32, f32)>,
|
||
/// Whether Space is held, temporarily routing left drag to canvas panning.
|
||
pub space_pan: bool,
|
||
}
|
||
|
||
impl Default for ToolState {
|
||
fn default() -> Self {
|
||
Self {
|
||
active_tool: Tool::Brush,
|
||
is_drawing: false,
|
||
last_stroke_pos: None,
|
||
brush_accumulated_dist: 0.0,
|
||
brush_size: 20.0,
|
||
brush_opacity: 1.0,
|
||
brush_hardness: 0.8,
|
||
brush_style: BrushStyle::Round,
|
||
brush_spacing: 1.0,
|
||
brush_color_variant: false,
|
||
brush_variant_amount: 0.0,
|
||
brush_angle: 0.0,
|
||
brush_size_variance: 0.0,
|
||
brush_angle_variance: 0.0,
|
||
brush_cyclic_color: false,
|
||
brush_cyclic_speed: 0.5,
|
||
last_update_instant: std::time::Instant::now(),
|
||
imported_brushes: Vec::new(),
|
||
active_brush_preset: None,
|
||
is_resizing_brush: false,
|
||
brush_resize_start_size: 20.0,
|
||
brush_resize_start_pos: None,
|
||
last_click_time: std::time::Instant::now(),
|
||
last_click_pos: None,
|
||
space_pan: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Messages the application handles.
|
||
#[derive(Debug, Clone)]
|
||
#[allow(dead_code)]
|
||
pub enum Message {
|
||
// ── Tool selection ──────────────────────────────────
|
||
ToolSelected(Tool),
|
||
ToolSlotClicked(usize, Tool),
|
||
/// Opens or closes a slot's subtool chooser without changing the active tool.
|
||
SubtoolsToggle(usize),
|
||
/// Dismisses a transient toolbox or menu popup after an uncaptured pointer press.
|
||
OverlayOutsideClick,
|
||
|
||
// ── Color ───────────────────────────────────────────
|
||
FgColorChanged([u8; 4]),
|
||
BgColorChanged([u8; 4]),
|
||
/// Signals the start of a color-picker drag with the initial color.
|
||
ColorDragStarted([u8; 4]),
|
||
/// Signals the end of a color-picker drag; triggers deferred shape sync.
|
||
ColorDragEnded,
|
||
SwapColors,
|
||
ResetColors,
|
||
ColorTabChanged(usize),
|
||
|
||
// ── Custom shapes panel ─────────────────────────────
|
||
RefreshCustomShapes,
|
||
|
||
// ── Canvas interaction ──────────────────────────────
|
||
CanvasPointerPressed {
|
||
x: f32,
|
||
y: f32,
|
||
captured_at: std::time::Instant,
|
||
},
|
||
CanvasPointerMoved {
|
||
x: f32,
|
||
y: f32,
|
||
captured_at: std::time::Instant,
|
||
},
|
||
CanvasPointerReleased,
|
||
CanvasPointerRightClicked {
|
||
x: f32,
|
||
y: f32,
|
||
screen_x: f32,
|
||
screen_y: f32,
|
||
},
|
||
CanvasContextMenuClose,
|
||
CanvasPanZoom {
|
||
zoom: f32,
|
||
pan_offset: Vector,
|
||
},
|
||
CanvasZoomSet(f32),
|
||
CanvasZoomRelative(f32),
|
||
FitToArea,
|
||
CanvasSize((f32, f32)),
|
||
CanvasCursorPos {
|
||
x: u32,
|
||
y: u32,
|
||
},
|
||
/// Lightweight no-op message emitted by the canvas overlay on every
|
||
/// `CursorMoved` event. Its sole purpose is to trigger a `view()`
|
||
/// rebuild so the crosshair overlay redraws at the latest cursor
|
||
/// position during idle (non-drawing) pointer movement.
|
||
CursorOverlayRedraw,
|
||
ModifiersChanged(iced::keyboard::Modifiers),
|
||
SpacePanChanged(bool),
|
||
|
||
// ── Engine operations ───────────────────────────────
|
||
CompositeRefresh,
|
||
/// Paces visible stroke updates independently from raw pointer message frequency.
|
||
CanvasFrameTick,
|
||
/// Polled after end_stroke to commit any pending history snapshots
|
||
/// that the background thread hadn't finished yet.
|
||
CompositeRefreshPending,
|
||
Undo,
|
||
Redo,
|
||
|
||
// ── Layer operations ────────────────────────────────
|
||
LayerSelect(u64),
|
||
LayerAdd,
|
||
LayerDelete(u64),
|
||
LayerToggleVisibility(u64),
|
||
LayerToggleLock(u64),
|
||
LayerSetOpacity(u64, f32),
|
||
LayerMergeDown,
|
||
LayerFlatten,
|
||
LayerMoveUp(u64),
|
||
LayerMoveDown(u64),
|
||
LayerSetBlendMode(u64, BlendMode),
|
||
LayerSetFillOpacity(u64, f32),
|
||
LayerAddGroup,
|
||
LayerToggleCollapse(u64),
|
||
LayerAddMask(u64),
|
||
LayerRemoveMask(u64),
|
||
LayerToggleMask(u64),
|
||
LayerSetEditMask(bool),
|
||
LayerToggleEditMask,
|
||
|
||
// ── History ─────────────────────────────────────────
|
||
HistoryJumpTo(i32),
|
||
|
||
// ── Text tool ───────────────────────────────────────
|
||
TextSizeChanged(f32),
|
||
TextColorChanged([u8; 4]),
|
||
TextAlignChanged(String),
|
||
TextAccept,
|
||
TextCancel,
|
||
|
||
// ── Layer styles ────────────────────────────────────
|
||
LayerStyleToggle(u32),
|
||
LayerStyleSelect(usize),
|
||
LayerStyleApply,
|
||
LayerStyleCancel,
|
||
LayerStyleUpdateParam(usize, String, f32),
|
||
LayerStyleUpdateBlendMode(usize, String),
|
||
LayerStyleUpdateColor(usize, [u8; 4]),
|
||
LayerStyleUpdateString(usize, String, String),
|
||
LayerStyleUpdateShadowColor(usize, [u8; 4]),
|
||
LayerStyleUpdateShadowBlend(usize, String),
|
||
LayerStyleUpdateShadowOpacity(usize, f32),
|
||
ShowStyleColorPicker(usize),
|
||
ShowStyleShadowColorPicker(usize),
|
||
HideStyleColorPicker,
|
||
OpenColorPicker(crate::color_picker::ColorPickerTarget, [u8; 4]),
|
||
CloseColorPicker,
|
||
ColorPickerTabChanged(usize),
|
||
ColorPickerHexChanged(String),
|
||
PopupColorChanged([u8; 4]),
|
||
OpenLayerStyleDialog,
|
||
LayerStyleDialogDragStart(f32, f32),
|
||
LayerStyleDialogDragMove(f32, f32),
|
||
LayerStyleDialogDragEnd,
|
||
LayerStyleAdd,
|
||
|
||
// ── Brushes ─────────────────────────────────────────
|
||
BrushStyleSelected(BrushStyle),
|
||
BrushPresetSelected(hcie_engine_api::BrushPreset),
|
||
BrushCategorySelected(crate::panels::brushes::BrushCategory),
|
||
BrushSizeChanged(f32),
|
||
BrushSizeRelative(f32),
|
||
BrushOpacityChanged(f32),
|
||
BrushHardnessChanged(f32),
|
||
BrushFlowChanged(f32),
|
||
BrushSpacingChanged(f32),
|
||
BrushColorVariantToggled(bool),
|
||
BrushVariantAmountChanged(f32),
|
||
BrushAngleChanged(f32),
|
||
BrushSizeVarianceChanged(f32),
|
||
BrushAngleVarianceChanged(f32),
|
||
BrushCyclicColorToggled(bool),
|
||
BrushCyclicSpeedChanged(f32),
|
||
BrushImportAbr,
|
||
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
|
||
ResetToolDefaults,
|
||
|
||
// ── Per-tool options (selection / spray / vector / text / gradient) ─
|
||
SelectionToleranceChanged(u8),
|
||
SelectionContiguousToggled(bool),
|
||
SelectionAntiAliasToggled(bool),
|
||
SelectionFeatherChanged(f32),
|
||
SprayParticleSizeChanged(f32),
|
||
SprayDensityChanged(u32),
|
||
GradientTypeChanged(u32),
|
||
VectorStrokeChanged(f32),
|
||
VectorOpacityChanged(f32),
|
||
VectorFillToggled(bool),
|
||
VectorRadiusChanged(f32),
|
||
VectorPointsChanged(u32),
|
||
VectorSidesChanged(u32),
|
||
VectorAngleChanged(f32),
|
||
TextFontChanged(String),
|
||
TextAngleChanged(f32),
|
||
TextOrientationChanged(String),
|
||
TextContentChanged(String),
|
||
TextEditorAction(iced::widget::text_editor::Action),
|
||
TextCommit,
|
||
ToolApplyToNewLayerToggled(bool),
|
||
|
||
// ── Filters ─────────────────────────────────────────
|
||
FilterSelect(FilterType),
|
||
FilterApply,
|
||
FilterReset,
|
||
FilterParamChanged(String, serde_json::Value),
|
||
FilterPreviewToggle(bool),
|
||
FilterPreviewApply,
|
||
FilterPreviewRestore,
|
||
FilterCategoryToggle(String),
|
||
|
||
// ── Dialogs ─────────────────────────────────────────
|
||
DialogOpen(ActiveDialog),
|
||
DialogClose,
|
||
DialogCloseSave,
|
||
DialogCloseDiscard,
|
||
|
||
// ── New Image Dialog ────────────────────────────────
|
||
DialogNewImageName(String),
|
||
DialogNewImageWidth(u32),
|
||
DialogNewImageHeight(u32),
|
||
DialogNewImageTransparent(bool),
|
||
DialogNewImagePreset(u32, u32),
|
||
DialogNewImageCreate,
|
||
|
||
// ── Adjustments Dialog ──────────────────────────────
|
||
AdjBrightness(f32),
|
||
AdjContrast(f32),
|
||
AdjHue(f32),
|
||
AdjSaturation(f32),
|
||
AdjLightness(f32),
|
||
AdjApply,
|
||
|
||
// ── Selection Dialog ────────────────────────────────
|
||
SelectionOpValue(f32),
|
||
SelectionOpApply,
|
||
|
||
// ── Image Size Dialog ──────────────────────────────
|
||
DialogImageSizeWidth(u32),
|
||
DialogImageSizeHeight(u32),
|
||
DialogImageSizeConstrain(bool),
|
||
DialogImageSizeApply,
|
||
|
||
// ── Canvas Size Dialog ─────────────────────────────
|
||
DialogCanvasSizeWidth(u32),
|
||
DialogCanvasSizeHeight(u32),
|
||
DialogCanvasSizeAnchor(usize, usize),
|
||
DialogCanvasSizeApply,
|
||
|
||
// ── Selection tools ─────────────────────────────────
|
||
SelectionDragStart(f32, f32),
|
||
SelectionDragMove(f32, f32),
|
||
SelectionDragEnd,
|
||
SelectAll,
|
||
Deselect,
|
||
SelectInverse,
|
||
SelectionModeChanged(selection::SelectionMode),
|
||
SelectionSave,
|
||
SelectionLoad,
|
||
QuickMaskToggle,
|
||
ClearPixels,
|
||
|
||
// ── Selection transform ─────────────────────────────
|
||
TransformDragStart(f32, f32),
|
||
TransformDragMove(f32, f32),
|
||
TransformDragEnd,
|
||
TransformApply,
|
||
TransformCancel,
|
||
|
||
// ── Vector tools ────────────────────────────────────
|
||
VectorDrawStart(f32, f32),
|
||
VectorDrawMove(f32, f32),
|
||
VectorDrawEnd,
|
||
|
||
// ── Vector select / edit ────────────────────────────
|
||
VectorSelectClick {
|
||
x: f32,
|
||
y: f32,
|
||
},
|
||
VectorSelectDragStart {
|
||
x: f32,
|
||
y: f32,
|
||
},
|
||
VectorSelectDragMove {
|
||
x: f32,
|
||
y: f32,
|
||
},
|
||
VectorSelectDragEnd,
|
||
VectorShapeDelete,
|
||
VectorShapeMoveUp,
|
||
VectorShapeMoveDown,
|
||
VectorShapeRename(usize, String),
|
||
VectorShapeSelect(Option<usize>),
|
||
VectorShapeApply,
|
||
VectorShapeCancel,
|
||
VectorShapeFlipH,
|
||
VectorShapeFlipV,
|
||
|
||
// ── Geometry panel controls ──────────────────────────
|
||
GeometryBoundsX1(f32),
|
||
GeometryBoundsY1(f32),
|
||
GeometryBoundsX2(f32),
|
||
GeometryBoundsY2(f32),
|
||
GeometryAngleChanged(f32),
|
||
GeometryFillToggled(bool),
|
||
GeometryFillColorChanged([u8; 4]),
|
||
GeometryStrokeColorChanged([u8; 4]),
|
||
GeometryOpacityChanged(f32),
|
||
GeometryHardnessChanged(f32),
|
||
GeometryBoolShapeA(Option<usize>),
|
||
GeometryBoolShapeB(Option<usize>),
|
||
GeometryBooleanOp(hcie_engine_api::VectorBooleanOp),
|
||
GeometryLineCapStart(hcie_engine_api::LineCap),
|
||
GeometryLineCapEnd(hcie_engine_api::LineCap),
|
||
|
||
// ── Lasso / polygon selection ───────────────────────
|
||
LassoDragMove(f32, f32),
|
||
LassoDragEnd,
|
||
PolygonAddPoint(f32, f32),
|
||
PolygonClose,
|
||
|
||
// ── Gradient tool drag ──────────────────────────────
|
||
GradientDragStart(f32, f32),
|
||
GradientDragMove(f32, f32),
|
||
GradientDragEnd,
|
||
|
||
// ── Crop tool ──────────────────────────────────────
|
||
CropDragStart(f32, f32),
|
||
CropDragMove(f32, f32),
|
||
CropDragEnd,
|
||
CropConfirm,
|
||
CropCancel,
|
||
|
||
// ── Script Editor ───────────────────────────────────
|
||
ScriptEditorAction(iced::widget::text_editor::Action),
|
||
ScriptTextChanged(String),
|
||
ScriptRun,
|
||
ScriptClear,
|
||
|
||
// ── AI Chat ─────────────────────────────────────────
|
||
AiChatInput(String),
|
||
AiChatSend,
|
||
AiChatClear,
|
||
AiChatProviderChanged(String),
|
||
AiChatUrlChanged(String),
|
||
AiChatModelChanged(String),
|
||
AiChatReasoningToggled(bool),
|
||
AiChatCanvasContextToggled(bool),
|
||
/// Switch the active sub-tab in the AI Assistant panel (0 = ComfyUI, 1 = AI Chat).
|
||
AiAssistantTabChanged(usize),
|
||
/// Trigger a ComfyUI connection status check.
|
||
ComfyUiCheckStatus,
|
||
/// Update the ComfyUI server URL.
|
||
ComfyUiUrlChanged(String),
|
||
AiChatStreamChunk(String),
|
||
AiChatReasoningChunk(String),
|
||
AiChatStreamFinished(Result<String, String>),
|
||
AiChatCancel,
|
||
AiChatCopyTranscript,
|
||
|
||
// ── AI Script Generator ─────────────────────────────
|
||
AiScriptPromptChanged(String),
|
||
AiScriptProviderChanged(String),
|
||
AiScriptModelChanged(String),
|
||
AiScriptUrlChanged(String),
|
||
AiScriptGenerate,
|
||
AiScriptRun,
|
||
AiScriptCopy,
|
||
AiScriptCopyOutput,
|
||
AiScriptClear,
|
||
AiScriptResult(Result<String, String>),
|
||
|
||
// ── Clipboard ───────────────────────────────────────
|
||
CopyImage,
|
||
CutImage,
|
||
PasteImage,
|
||
PasteAsNewLayer,
|
||
CopyText(String),
|
||
PasteText,
|
||
ClipboardResult(Result<Option<String>, String>),
|
||
ImageCopied(Result<(), String>),
|
||
ImagePasted(Result<Option<(Vec<u8>, u32, u32)>, String>),
|
||
ImagePastedAsNewLayer(Result<Option<(Vec<u8>, u32, u32)>, String>),
|
||
|
||
// ── File I/O (with rfd) ─────────────────────────────
|
||
OpenFileRfd,
|
||
SaveFileAs,
|
||
FileDialogClosed(Option<std::path::PathBuf>),
|
||
|
||
// ── Dock Layout ─────────────────────────────────────
|
||
PaneClicked(iced::widget::pane_grid::Pane),
|
||
PaneDragged(iced::widget::pane_grid::DragEvent),
|
||
/// Begins a reliable custom drag from the visible dock title content.
|
||
DockHeaderDragStart(iced::widget::pane_grid::Pane),
|
||
PaneResized(iced::widget::pane_grid::ResizeEvent),
|
||
PaneClose(iced::widget::pane_grid::Pane),
|
||
PaneMaximize(iced::widget::pane_grid::Pane),
|
||
/// Moves a tool pane to the fixed right auto-hide rail.
|
||
PaneAutoHide(iced::widget::pane_grid::Pane),
|
||
/// Moves a docked tool into an in-viewport floating card.
|
||
PaneFloat(iced::widget::pane_grid::Pane),
|
||
/// Begins dragging a floating card header.
|
||
FloatingDragStart(PaneType),
|
||
/// Raises a clicked floating card without beginning a drag.
|
||
FloatingFocus(PaneType),
|
||
/// Redocks a floating tool beside Canvas.
|
||
FloatingRedock(PaneType),
|
||
/// Minimizes a floating tool to the fixed right auto-hide rail.
|
||
FloatingAutoHide(PaneType),
|
||
/// Closes a floating tool placement.
|
||
FloatingClose(PaneType),
|
||
/// Shared full-window logical cursor feed for dock and dialog drags.
|
||
DockPointerMoved(f32, f32),
|
||
/// Ends active full-window floating and dialog drags.
|
||
DockPointerReleased,
|
||
/// Clears click-only or outside-release dock state after PaneGrid handles the release event.
|
||
DockReleaseCleanup,
|
||
/// Tracks the current logical viewport for geometry and clamping.
|
||
DockViewportChanged(iced::Size),
|
||
/// Opens one temporary auto-hide overlay.
|
||
AutoHideActivate(PaneType),
|
||
/// Closes the temporary overlay without changing placement.
|
||
AutoHideDismiss,
|
||
/// Pins an auto-hidden panel back into PaneGrid.
|
||
AutoHideRestore(PaneType),
|
||
|
||
// ── File I/O ────────────────────────────────────────
|
||
NewDocument(u32, u32),
|
||
DocSwitch(usize),
|
||
/// Requests closure of one document tab without closing the application window.
|
||
DocClose(usize),
|
||
OpenFile,
|
||
FileOpened(Result<std::path::PathBuf, String>),
|
||
SaveFile,
|
||
|
||
// ── Menu ─────────────────────────────────────────────
|
||
MenuOpen(usize),
|
||
MenuClose,
|
||
/// Update the title bar search text.
|
||
TitleSearchChanged(String),
|
||
/// Toggle the panel list popup in the title bar.
|
||
PanelListToggle,
|
||
/// Executes a semantic command supplied by an enabled menu leaf.
|
||
MenuCommand(crate::panels::menus::MenuCommand),
|
||
|
||
// ── Window ───────────────────────────────────────────
|
||
WindowDrag,
|
||
WindowMinimize,
|
||
WindowMaximize,
|
||
WindowClose,
|
||
WindowId(iced::window::Id),
|
||
ScreenshotCapture,
|
||
ScreenshotReady(iced::window::Screenshot),
|
||
ThemeChanged(crate::theme::ThemePreset),
|
||
|
||
// ── Dock Profiles ───────────────────────────────────
|
||
DockProfileSave(String),
|
||
DockProfileLoad(String),
|
||
DockProfileRename(String, String),
|
||
DockProfileDelete(String),
|
||
DockProfileRenameStart(String),
|
||
DockProfileRenameConfirm,
|
||
DockProfileRenameCancel,
|
||
DockProfileNewNameChanged(String),
|
||
DockProfileRenameChanged(String),
|
||
DockProfileDialogShow,
|
||
DockProfileDialogHide,
|
||
|
||
// ── Misc ────────────────────────────────────────────
|
||
NoOp,
|
||
|
||
// ── Tablet / touch feed ─────────────────────────────
|
||
TabletTouch {
|
||
x: f32,
|
||
y: f32,
|
||
pressed: bool,
|
||
},
|
||
|
||
// ── Contextual key actions (resolved in update with self) ──
|
||
EscapePressed,
|
||
EnterPressed,
|
||
|
||
// ── Image Viewer ────────────────────────────────────
|
||
ViewerToggle,
|
||
ViewerSetMode(crate::viewer::ViewerMode),
|
||
ViewerNavigate(std::path::PathBuf),
|
||
ViewerSelectFile(std::path::PathBuf),
|
||
ViewerOpenFile(std::path::PathBuf),
|
||
ViewerRefresh,
|
||
ViewerFullscreen(bool),
|
||
ViewerPrev,
|
||
ViewerNext,
|
||
ViewerEnter,
|
||
/// Toggle the expanded/collapsed state of a directory tree node.
|
||
ViewerToggleNode(std::path::PathBuf),
|
||
/// Toggle between light and dark theme presets within the viewer.
|
||
ViewerThemeToggle,
|
||
|
||
// ── SVG Editor ──────────────────────────────────────
|
||
/// Open the SVG node editor for a vector SvgShape.
|
||
SvgEditorOpen {
|
||
layer_id: u64,
|
||
shape_idx: usize,
|
||
},
|
||
SvgEditorSelectPoly(usize),
|
||
SvgEditorSelectNode {
|
||
polygon: usize,
|
||
node: usize,
|
||
},
|
||
SvgEditorClearSelection,
|
||
SvgEditorMoveNode {
|
||
polygon: usize,
|
||
node: usize,
|
||
x: f32,
|
||
y: f32,
|
||
},
|
||
SvgEditorCanvasReleased,
|
||
SvgEditorCanvasAddNode {
|
||
polygon: usize,
|
||
edge: usize,
|
||
},
|
||
SvgEditorXChanged(String),
|
||
SvgEditorYChanged(String),
|
||
SvgEditorPan {
|
||
dx: f32,
|
||
dy: f32,
|
||
},
|
||
SvgEditorZoom(f32),
|
||
SvgEditorFit,
|
||
SvgEditorSnapChanged(bool),
|
||
SvgEditorGridChanged(String),
|
||
SvgEditorAddNode,
|
||
SvgEditorRemoveNode,
|
||
SvgEditorSave,
|
||
SvgEditorCancel,
|
||
|
||
// ── Toolbox expand/collapse ─────────────────────────
|
||
SidebarToggleExpanded,
|
||
}
|
||
|
||
/// Convert a filter_id string to a FilterType enum.
|
||
fn filter_id_to_type(filter_id: &str) -> FilterType {
|
||
match filter_id {
|
||
"box_blur" => FilterType::BoxBlur,
|
||
"gaussian_blur" => FilterType::GaussianBlur,
|
||
"motion_blur" => FilterType::MotionBlur,
|
||
"sharpen" | "unsharp_mask" => FilterType::UnsharpMask,
|
||
"mosaic" => FilterType::Mosaic,
|
||
"pinch" => FilterType::Pinch,
|
||
"twirl" => FilterType::Twirl,
|
||
"oil_paint" => FilterType::OilPaint,
|
||
"crystallize" => FilterType::Crystallize,
|
||
"levels" => FilterType::Levels,
|
||
"vibrance" => FilterType::Vibrance,
|
||
"exposure" => FilterType::Exposure,
|
||
"posterize" => FilterType::Posterize,
|
||
"threshold" => FilterType::Threshold,
|
||
"channel_mixer" => FilterType::ChannelMixer,
|
||
"black_and_white" | "bw" => FilterType::BlackAndWhite,
|
||
"selective_color" => FilterType::SelectiveColor,
|
||
"gamma_correction" | "gamma" => FilterType::GammaCorrection,
|
||
"extract_channel" => FilterType::ExtractChannel,
|
||
"gradient_map" => FilterType::GradientMap,
|
||
"gaussian_blur_gamma" => FilterType::GaussianBlurGamma,
|
||
"box_blur_gamma" => FilterType::BoxBlurGamma,
|
||
"median_filter" | "median" => FilterType::MedianFilter,
|
||
"dehaze" => FilterType::Dehaze,
|
||
"noise_pattern" => FilterType::NoisePattern,
|
||
_ => {
|
||
log::warn!(
|
||
"Unknown filter_id: {}, defaulting to GaussianBlur",
|
||
filter_id
|
||
);
|
||
FilterType::GaussianBlur
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Default parameters for procedural texture filters.
|
||
///
|
||
/// Textures have no `FilterType` variant and no parameter dialog — they apply
|
||
/// immediately with hardcoded default params (mirrors EGUI `menus.rs:561-655`).
|
||
fn default_texture_params(filter_id: &str) -> serde_json::Value {
|
||
match filter_id {
|
||
"texture_clouds" => serde_json::json!({"scale": 1.0, "roughness": 0.5}),
|
||
"texture_grass" => serde_json::json!({"scale": 1.0, "roughness": 0.5}),
|
||
"texture_sand" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0})
|
||
}
|
||
"texture_dirt" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "darkness": 0.0})
|
||
}
|
||
"texture_water" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "depth": 0.0})
|
||
}
|
||
"texture_rain" => serde_json::json!({
|
||
"scale": 1.0, "density": 0.5, "angle_deg": 45.0, "drop_len": 10.0
|
||
}),
|
||
"texture_stone" => serde_json::json!({"scale": 1.0, "roughness": 0.5}),
|
||
"texture_bricks" => {
|
||
serde_json::json!({"mortar_thick": 1.0, "brick_w": 40.0, "brick_h": 20.0})
|
||
}
|
||
"texture_wood_oak" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0})
|
||
}
|
||
"texture_wood_pine" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0})
|
||
}
|
||
"texture_wood_dark" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0})
|
||
}
|
||
"texture_metal_steel" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0})
|
||
}
|
||
"texture_metal_gold" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0})
|
||
}
|
||
"texture_metal_copper" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0})
|
||
}
|
||
"texture_linen" => {
|
||
serde_json::json!({"scale": 1.0, "roughness": 0.5, "thread_density": 5.0})
|
||
}
|
||
"texture_glass" => {
|
||
serde_json::json!({"roughness": 0.5, "cell_size": 30.0, "tint_blue": 0.5})
|
||
}
|
||
"sky_replacement" => serde_json::json!({
|
||
"tolerance": 0.5, "feather": 0, "light_match": false, "sky_path": ""
|
||
}),
|
||
_ => serde_json::json!({}),
|
||
}
|
||
}
|
||
|
||
/// Convert a FilterType enum to a user-facing display label.
|
||
///
|
||
/// Mirrors the naming used in the Filters panel so history entries read
|
||
/// naturally (e.g. "Apply Box Blur" rather than "Apply box_blur").
|
||
fn filter_label(filter: FilterType) -> &'static str {
|
||
match filter {
|
||
FilterType::BoxBlur => "Box Blur",
|
||
FilterType::GaussianBlur => "Gaussian Blur",
|
||
FilterType::MotionBlur => "Motion Blur",
|
||
FilterType::UnsharpMask => "Unsharp Mask",
|
||
FilterType::Mosaic => "Mosaic",
|
||
FilterType::Pinch => "Pinch",
|
||
FilterType::Twirl => "Twirl",
|
||
FilterType::OilPaint => "Oil Paint",
|
||
FilterType::Crystallize => "Crystallize",
|
||
FilterType::Levels => "Levels",
|
||
FilterType::Vibrance => "Vibrance",
|
||
FilterType::Exposure => "Exposure",
|
||
FilterType::Posterize => "Posterize",
|
||
FilterType::Threshold => "Threshold",
|
||
FilterType::ChannelMixer => "Channel Mixer",
|
||
FilterType::BlackAndWhite => "Black & White",
|
||
FilterType::SelectiveColor => "Selective Color",
|
||
FilterType::GammaCorrection => "Gamma Correction",
|
||
FilterType::ExtractChannel => "Extract Channel",
|
||
FilterType::GradientMap => "Gradient Map",
|
||
FilterType::GaussianBlurGamma => "Gamma-aware Gaussian Blur",
|
||
FilterType::BoxBlurGamma => "Gamma-aware Box Blur",
|
||
FilterType::MedianFilter => "Median Filter",
|
||
FilterType::Dehaze => "Dehaze",
|
||
FilterType::NoisePattern => "Noise Pattern",
|
||
}
|
||
}
|
||
|
||
/// Convert a FilterType enum to the engine's string filter ID.
|
||
fn filter_type_to_id(filter: FilterType) -> &'static str {
|
||
match filter {
|
||
FilterType::BoxBlur => "box_blur",
|
||
FilterType::GaussianBlur => "gaussian_blur",
|
||
FilterType::MotionBlur => "motion_blur",
|
||
FilterType::UnsharpMask => "sharpen",
|
||
FilterType::Mosaic => "mosaic",
|
||
FilterType::Pinch => "pinch",
|
||
FilterType::Twirl => "twirl",
|
||
FilterType::OilPaint => "oil_paint",
|
||
FilterType::Crystallize => "crystallize",
|
||
FilterType::Levels => "levels",
|
||
FilterType::Vibrance => "vibrance",
|
||
FilterType::Exposure => "exposure",
|
||
FilterType::Posterize => "posterize",
|
||
FilterType::Threshold => "threshold",
|
||
FilterType::ChannelMixer => "channel_mixer",
|
||
FilterType::BlackAndWhite => "black_and_white",
|
||
FilterType::SelectiveColor => "selective_color",
|
||
FilterType::GammaCorrection => "gamma_correction",
|
||
FilterType::ExtractChannel => "extract_channel",
|
||
FilterType::GradientMap => "gradient_map",
|
||
FilterType::GaussianBlurGamma => "gaussian_blur_gamma",
|
||
FilterType::BoxBlurGamma => "box_blur_gamma",
|
||
FilterType::MedianFilter => "median_filter",
|
||
FilterType::Dehaze => "dehaze",
|
||
FilterType::NoisePattern => "noise_pattern",
|
||
}
|
||
}
|
||
|
||
/// Build a BrushTip from the current tool state.
|
||
///
|
||
/// Converts the tool state's brush parameters into an engine BrushTip
|
||
/// that can be passed to `engine.set_brush_tip()` before starting a stroke.
|
||
fn build_brush_tip(state: &ToolState) -> BrushTip {
|
||
BrushTip {
|
||
style: state.brush_style,
|
||
size: state.brush_size,
|
||
opacity: state.brush_opacity,
|
||
hardness: state.brush_hardness,
|
||
spacing: state.brush_spacing,
|
||
color_variant: state.brush_color_variant,
|
||
variant_amount: state.brush_variant_amount,
|
||
angle: state.brush_angle,
|
||
roundness: state.brush_size_variance,
|
||
rotation_random: state.brush_angle_variance,
|
||
..BrushTip::default()
|
||
}
|
||
}
|
||
|
||
|
||
/// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`.
|
||
///
|
||
/// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with
|
||
/// all shared rendering fields copied and protocol-only time dynamics left at defaults.
|
||
/// **Side Effects / Dependencies:** None.
|
||
fn protocol_tip_from_preset(source: &hcie_engine_api::brush::BrushTip) -> BrushTip {
|
||
let mut tip = BrushTip::default();
|
||
tip.style = protocol_style_from_preset(source.style);
|
||
tip.size = source.size;
|
||
tip.opacity = source.opacity;
|
||
tip.hardness = source.hardness;
|
||
tip.spacing = source.spacing;
|
||
tip.flow = source.flow;
|
||
tip.jitter_amount = source.jitter_amount;
|
||
tip.scatter_amount = source.scatter_amount;
|
||
tip.angle = source.angle;
|
||
tip.roundness = source.roundness;
|
||
tip.spray_particle_size = source.spray_particle_size;
|
||
tip.spray_density = source.spray_density;
|
||
tip.bitmap_pixels = source.bitmap_pixels.clone();
|
||
tip.bitmap_w = source.bitmap_w;
|
||
tip.bitmap_h = source.bitmap_h;
|
||
tip.color_variant = source.color_variant;
|
||
tip.variant_amount = source.variant_amount;
|
||
tip.density = source.density;
|
||
tip.drawing_angle = source.drawing_angle;
|
||
tip.rotation_random = source.rotation_random;
|
||
tip
|
||
}
|
||
|
||
/// Maps the brush-engine preset enum to the protocol enum used by GUI tool state.
|
||
///
|
||
/// **Arguments:** `style` is a serialized preset style. **Returns:** Its protocol equivalent.
|
||
/// **Side Effects / Dependencies:** None.
|
||
fn protocol_style_from_preset(style: hcie_engine_api::brush::BrushStyle) -> BrushStyle {
|
||
use hcie_engine_api::brush::BrushStyle as PresetStyle;
|
||
match style {
|
||
PresetStyle::Round => BrushStyle::Round,
|
||
PresetStyle::Square => BrushStyle::Square,
|
||
PresetStyle::HardRound => BrushStyle::HardRound,
|
||
PresetStyle::SoftRound => BrushStyle::SoftRound,
|
||
PresetStyle::Star => BrushStyle::Star,
|
||
PresetStyle::Noise => BrushStyle::Noise,
|
||
PresetStyle::Texture => BrushStyle::Texture,
|
||
PresetStyle::Spray => BrushStyle::Spray,
|
||
PresetStyle::Pencil => BrushStyle::Pencil,
|
||
PresetStyle::Pen => BrushStyle::Pen,
|
||
PresetStyle::Calligraphy => BrushStyle::Calligraphy,
|
||
PresetStyle::Oil => BrushStyle::Oil,
|
||
PresetStyle::Charcoal => BrushStyle::Charcoal,
|
||
PresetStyle::Leaf => BrushStyle::Leaf,
|
||
PresetStyle::Rock => BrushStyle::Rock,
|
||
PresetStyle::Meadow => BrushStyle::Meadow,
|
||
PresetStyle::Wood => BrushStyle::Wood,
|
||
PresetStyle::Watercolor => BrushStyle::Watercolor,
|
||
PresetStyle::Marker => BrushStyle::Marker,
|
||
PresetStyle::Sketch => BrushStyle::Sketch,
|
||
PresetStyle::Hatch => BrushStyle::Hatch,
|
||
PresetStyle::Glow => BrushStyle::Glow,
|
||
PresetStyle::Airbrush => BrushStyle::Airbrush,
|
||
PresetStyle::Crayon => BrushStyle::Crayon,
|
||
PresetStyle::WetPaint => BrushStyle::WetPaint,
|
||
PresetStyle::InkPen => BrushStyle::InkPen,
|
||
PresetStyle::Clouds => BrushStyle::Clouds,
|
||
PresetStyle::Dirt => BrushStyle::Dirt,
|
||
PresetStyle::Tree => BrushStyle::Tree,
|
||
PresetStyle::Bristle => BrushStyle::Bristle,
|
||
PresetStyle::Mixer => BrushStyle::Mixer,
|
||
PresetStyle::Blender => BrushStyle::Blender,
|
||
PresetStyle::Bitmap => BrushStyle::Bitmap,
|
||
}
|
||
}
|
||
|
||
/// Convert a display label back to a PaneType for dock profile loading.
|
||
fn pane_type_from_label(label: &str) -> Option<crate::dock::state::PaneType> {
|
||
use crate::dock::state::PaneType;
|
||
match label {
|
||
"Canvas" => Some(PaneType::Canvas),
|
||
"Layers" => Some(PaneType::Layers),
|
||
"History" => Some(PaneType::History),
|
||
"Brushes & Tips" => Some(PaneType::Brushes),
|
||
"Filters" => Some(PaneType::Filters),
|
||
"Color Palette" => Some(PaneType::ColorPicker),
|
||
"Properties" => Some(PaneType::Properties),
|
||
"Script" => Some(PaneType::Script),
|
||
"AI Assistant" => Some(PaneType::AiChat),
|
||
"Layer Details" => Some(PaneType::LayerDetails),
|
||
"Geometry" => Some(PaneType::Geometry),
|
||
"Tool Settings" => Some(PaneType::ToolSettings),
|
||
"AI Script Generator" => Some(PaneType::AiScript),
|
||
"Custom Shapes" => Some(PaneType::CustomShapes),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Helper to combine two bounding boxes of dirty regions.
|
||
fn union_regions(r1: Option<[u32; 4]>, r2: [u32; 4]) -> [u32; 4] {
|
||
match r1 {
|
||
Some(r) => [
|
||
r[0].min(r2[0]),
|
||
r[1].min(r2[1]),
|
||
r[2].max(r2[2]),
|
||
r[3].max(r2[3]),
|
||
],
|
||
None => r2,
|
||
}
|
||
}
|
||
|
||
/// Whether a tool is a vector-shape tool that uses the shared drag flow.
|
||
///
|
||
/// This includes both built-in vector shapes and custom SVG shapes loaded from
|
||
/// the engine's shape catalog (`Tool::CustomShape`), so they all share the same
|
||
/// canvas drag-to-draw interaction.
|
||
fn is_vector_shape_tool(tool: Tool) -> bool {
|
||
matches!(
|
||
tool,
|
||
Tool::VectorRect
|
||
| Tool::VectorCircle
|
||
| Tool::VectorLine
|
||
| Tool::VectorArrow
|
||
| Tool::VectorStar
|
||
| Tool::VectorPolygon
|
||
| Tool::VectorRhombus
|
||
| Tool::VectorCylinder
|
||
| Tool::VectorHeart
|
||
| Tool::VectorBubble
|
||
| Tool::VectorGear
|
||
| Tool::VectorCross
|
||
| Tool::VectorCrescent
|
||
| Tool::VectorBolt
|
||
| Tool::VectorArrow4
|
||
| Tool::CustomShape(_)
|
||
)
|
||
}
|
||
|
||
/// Clears mask-covered alpha values without creating an engine history entry.
|
||
///
|
||
/// **Purpose:** Starts a move transaction while deferring history creation until Apply, avoiding
|
||
/// the engine's standalone `Clear Selection` snapshot used by the Delete command.
|
||
///
|
||
/// **Logic & Workflow:** Visits one mask byte per canvas pixel and clears only the corresponding
|
||
/// layer alpha byte when coverage is non-zero, matching `Engine::clear_selection_pixels`.
|
||
///
|
||
/// **Arguments:** `pixels` is active-layer RGBA data and `mask` is one-byte selection coverage.
|
||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates `pixels`; performs no allocation,
|
||
/// engine call, or history operation.
|
||
fn clear_masked_pixels_without_history(pixels: &mut [u8], mask: &[u8]) {
|
||
for (index, coverage) in mask.iter().copied().enumerate() {
|
||
let alpha_index = index * 4 + 3;
|
||
if coverage > 0 && alpha_index < pixels.len() {
|
||
pixels[alpha_index] = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Restores a rectangular region from a stable transform preview baseline.
|
||
///
|
||
/// **Purpose:** Removes the prior floating preview without restoring the full canvas per pointer
|
||
/// event. **Logic & Workflow:** Copies each row in the clipped exclusive rectangle from `source`
|
||
/// to `destination`. **Arguments:** Buffers are full-canvas RGBA data, `canvas_width` is their row
|
||
/// stride, and `bounds` is `[x0, y0, x1, y1]`. **Returns:** Nothing.
|
||
/// **Side Effects / Dependencies:** Mutates only `destination` rows inside `bounds`; no allocation.
|
||
fn restore_preview_region(
|
||
destination: &mut [u8],
|
||
source: &[u8],
|
||
canvas_width: u32,
|
||
bounds: [u32; 4],
|
||
) {
|
||
let row_bytes = (bounds[2] - bounds[0]) as usize * 4;
|
||
for y in bounds[1]..bounds[3] {
|
||
let start = (y as usize * canvas_width as usize + bounds[0] as usize) * 4;
|
||
let end = start + row_bytes;
|
||
if end <= destination.len() && end <= source.len() {
|
||
destination[start..end].copy_from_slice(&source[start..end]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Applies a floating transform and records exactly one caller-labelled layer snapshot.
|
||
///
|
||
/// **Purpose:** Commits move and paste transforms atomically using the same inverse-affine sampler
|
||
/// as preview. **Logic & Workflow:** Reads the current non-preview layer, composites the transform,
|
||
/// installs final pixels, and pushes the supplied original baseline only when pixels changed.
|
||
///
|
||
/// **Arguments:** `engine` is the API boundary, `tr` is floating geometry, canvas dimensions define
|
||
/// raster clipping, `before_pixels` is the transaction baseline, and `history_name` labels undo.
|
||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates the active layer and may append one
|
||
/// engine history snapshot; emits DEBUG timing and transaction-boundary logs.
|
||
fn apply_transform_to_layer(
|
||
engine: &mut hcie_engine_api::Engine,
|
||
tr: &selection::SelectionTransform,
|
||
canvas_w: u32,
|
||
canvas_h: u32,
|
||
before_pixels: Vec<u8>,
|
||
history_name: &str,
|
||
) {
|
||
let started = std::time::Instant::now();
|
||
if tr.is_empty() || canvas_w == 0 || canvas_h == 0 {
|
||
return;
|
||
}
|
||
let Some(mut dest) = engine.get_active_layer_pixels() else {
|
||
return;
|
||
};
|
||
let active_idx = engine.active_layer_index();
|
||
let bounds = selection::transform::composite_transform(&mut dest, canvas_w, canvas_h, tr);
|
||
log::debug!(
|
||
"[TransformApply] begin history_name={history_name:?}, active_layer={active_idx}, bounds={bounds:?}"
|
||
);
|
||
if before_pixels != dest {
|
||
engine.set_active_layer_pixels(dest);
|
||
engine.push_active_layer_snapshot(before_pixels, active_idx, history_name);
|
||
}
|
||
log::debug!(
|
||
"[TransformApply] end history_name={history_name:?}, elapsed_us={}",
|
||
started.elapsed().as_micros()
|
||
);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod transform_transaction_tests {
|
||
use super::apply_transform_to_layer;
|
||
use crate::selection::SelectionTransform;
|
||
|
||
/// Verifies that move commit creates one correctly named history boundary.
|
||
///
|
||
/// **Purpose:** Prevents reintroduction of a preliminary `Clear Selection` snapshot before the
|
||
/// final move. **Logic & Workflow:** Starts from a transparent layer, applies one floating pixel
|
||
/// with an explicit original baseline, and checks history growth and description.
|
||
/// **Arguments & Returns:** No arguments or return value; assertions report transaction errors.
|
||
/// **Side Effects / Dependencies:** Uses an in-memory engine only and performs no I/O.
|
||
#[test]
|
||
fn move_commit_pushes_one_named_snapshot() {
|
||
let mut engine = hcie_engine_api::Engine::new(4, 4);
|
||
let baseline = engine.get_active_layer_pixels().unwrap();
|
||
let history_before = engine.history_len();
|
||
let transform = SelectionTransform {
|
||
pixels: vec![255, 0, 0, 255],
|
||
width: 1,
|
||
height: 1,
|
||
pos: iced::Vector::new(2.0, 1.0),
|
||
size: iced::Vector::new(1.0, 1.0),
|
||
rotation: 0.0,
|
||
};
|
||
|
||
apply_transform_to_layer(&mut engine, &transform, 4, 4, baseline, "Move Selection");
|
||
|
||
assert_eq!(engine.history_len(), history_before + 1);
|
||
assert_eq!(
|
||
engine.history_description(history_before).as_deref(),
|
||
Some("Move Selection")
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Renders the current floating selection and publishes only its old/new union as dirty.
|
||
///
|
||
/// **Purpose:** Keeps interactive transforms responsive while retaining the protected RefCell
|
||
/// dirty-region accumulator and shader partial-upload path.
|
||
///
|
||
/// **Logic & Workflow:** Restores only the previous transformed AABB from the stable base, invokes
|
||
/// the shared inverse-affine compositor for the current AABB, unions both with any pending dirty
|
||
/// region, and copies only that union into the stable shader recovery image.
|
||
///
|
||
/// **Arguments:** `doc` contains transform/composite state and `previous_bounds` identifies stale
|
||
/// preview pixels. **Returns:** Nothing. **Side Effects / Dependencies:** Mutates preview buffers,
|
||
/// the dirty-region RefCell, and render generation; logs bounds and elapsed time at DEBUG level.
|
||
fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32; 4]>) {
|
||
let started = std::time::Instant::now();
|
||
let canvas_width = doc.engine.canvas_width();
|
||
let canvas_height = doc.engine.canvas_height();
|
||
if let (Some(base), Some(bounds)) = (doc.transform_preview_base.as_ref(), previous_bounds) {
|
||
restore_preview_region(&mut doc.composite_raw, base, canvas_width, bounds);
|
||
}
|
||
let current_bounds = doc.selection_transform.as_ref().and_then(|transform| {
|
||
selection::transform::composite_transform(
|
||
&mut doc.composite_raw,
|
||
canvas_width,
|
||
canvas_height,
|
||
transform,
|
||
)
|
||
});
|
||
let changed_bounds = match (previous_bounds, current_bounds) {
|
||
(Some(previous), Some(current)) => Some(union_regions(Some(previous), current)),
|
||
(Some(bounds), None) | (None, Some(bounds)) => Some(bounds),
|
||
(None, None) => None,
|
||
};
|
||
if let Some(bounds) = changed_bounds {
|
||
let pending = doc.dirty_region.replace(None);
|
||
let upload_bounds = union_regions(pending, bounds);
|
||
doc.dirty_region.replace(Some(upload_bounds));
|
||
if let Err(error) =
|
||
doc.composite_pixels
|
||
.copy_region_from(&doc.composite_raw, canvas_width, upload_bounds)
|
||
{
|
||
log::error!("failed to stage transform preview: {error}");
|
||
doc.composite_pixels.replace(&doc.composite_raw);
|
||
doc.full_upload.replace(true);
|
||
}
|
||
doc.render_generation = doc.render_generation.wrapping_add(1);
|
||
log::debug!(
|
||
"[TransformPreview] previous={previous_bounds:?}, current={current_bounds:?}, upload={upload_bounds:?}, elapsed_us={}",
|
||
started.elapsed().as_micros()
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Rebuilds all GUI selection-rendering state from one raw mask.
|
||
///
|
||
/// **Purpose:** Makes selection texture work proportional to selection mutations rather than brush
|
||
/// refreshes. **Logic & Workflow:** Valid masks are encoded once into interior/border values and
|
||
/// cached behind `Arc`; empty or invalid masks clear all overlay state. **Arguments:** `doc` is the
|
||
/// target document and `mask` is an optional locally owned alpha mask. **Returns:** Nothing.
|
||
/// **Side Effects / Dependencies:** Updates selection caches and schedules exactly one GPU upload.
|
||
fn set_document_selection_mask(doc: &mut IcedDocument, mask: Option<Vec<u8>>) {
|
||
let width = doc.engine.canvas_width();
|
||
let height = doc.engine.canvas_height();
|
||
let valid_mask = mask.filter(|mask| mask.len() == (width * height) as usize);
|
||
if let Some(mask) = valid_mask {
|
||
let encoded = selection::state::encode_selection_texture(&mask, width, height);
|
||
if encoded.bounds.is_some() {
|
||
doc.selection_mask = Some(Arc::new(mask));
|
||
doc.selection_texture = Some(Arc::new(encoded.pixels));
|
||
doc.selection_bounds = encoded.bounds;
|
||
} else {
|
||
doc.selection_mask = None;
|
||
doc.selection_texture = None;
|
||
doc.selection_bounds = None;
|
||
}
|
||
} else {
|
||
doc.selection_mask = None;
|
||
doc.selection_texture = None;
|
||
doc.selection_bounds = None;
|
||
}
|
||
doc.selection_model_dirty = false;
|
||
doc.selection_mask_dirty.set(true);
|
||
}
|
||
|
||
/// Consumes one pending selection-cache synchronization request.
|
||
///
|
||
/// **Arguments:** `requested` is the document's mutation marker. **Returns:** Whether a rebuild must
|
||
/// run now. **Side Effects / Dependencies:** Resets the marker so ordinary composite refreshes are
|
||
/// constant-time until another selection operation explicitly marks it.
|
||
fn consume_selection_sync_request(requested: &mut bool) -> bool {
|
||
std::mem::take(requested)
|
||
}
|
||
|
||
/// Synchronizes cached selection rendering state after an engine selection mutation.
|
||
///
|
||
/// **Arguments:** `doc` is the document whose engine owns the source mask. **Returns:** Nothing.
|
||
/// **Logic & Workflow:** A clean marker is a constant-time no-op; a dirty marker clones and encodes
|
||
/// the engine mask once. **Side Effects / Dependencies:** Reads `hcie-engine-api` selection state and
|
||
/// may schedule a selection texture upload.
|
||
fn sync_document_selection_if_needed(doc: &mut IcedDocument) {
|
||
if !consume_selection_sync_request(&mut doc.selection_model_dirty) {
|
||
return;
|
||
}
|
||
let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||
set_document_selection_mask(doc, mask);
|
||
}
|
||
|
||
impl HcieIcedApp {
|
||
/// Combines the engine's newly generated selection with the previous mask.
|
||
///
|
||
/// **Arguments:** `previous` is the selection mask captured before an engine selection call.
|
||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Replaces the engine selection mask
|
||
/// through `hcie-engine-api` when add/subtract/intersect mode is active.
|
||
fn combine_current_selection(&mut self, previous: Option<Vec<u8>>) {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
|
||
let current_marker = doc.engine.history_current();
|
||
if current_marker != doc.selection_history_marker {
|
||
doc.selection_history.clear();
|
||
doc.selection_history_marker = current_marker;
|
||
}
|
||
doc.selection_history.push(previous.clone());
|
||
|
||
let Some(new_mask) = doc.engine.get_selection_mask().map(ToOwned::to_owned) else {
|
||
set_document_selection_mask(doc, None);
|
||
return;
|
||
};
|
||
let combined =
|
||
selection::state::combine_masks(previous.as_deref(), &new_mask, doc.selection_mode);
|
||
doc.engine.set_selection_mask(combined);
|
||
doc.selection_model_dirty = true;
|
||
}
|
||
|
||
/// Create the initial application state.
|
||
///
|
||
/// If `load_path` is provided, the file will be opened after initialization.
|
||
/// Returns a Task to capture the window ID on startup.
|
||
pub fn new(
|
||
load_path: Option<std::path::PathBuf>,
|
||
screenshot_request: Option<crate::cli::ScreenshotRequest>,
|
||
) -> (Self, Task<Message>) {
|
||
let mut engine = Engine::new(800, 600);
|
||
engine.pre_tile_all_layers();
|
||
let composite_raw = engine.get_composite_pixels();
|
||
|
||
let cached_layers = engine.layer_infos();
|
||
let history_len = engine.history_len();
|
||
let cached_history: Vec<(usize, String)> = (0..history_len)
|
||
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
||
.collect();
|
||
let history_current = engine.history_current();
|
||
|
||
let doc = IcedDocument {
|
||
engine,
|
||
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
|
||
composite_raw.clone(),
|
||
),
|
||
composite_raw,
|
||
name: "Untitled".to_string(),
|
||
source_path: None,
|
||
modified: false,
|
||
zoom: 1.0,
|
||
pan_offset: Vector::ZERO,
|
||
cached_layers,
|
||
cached_history,
|
||
history_current,
|
||
selection_rect: None,
|
||
vector_draw: None,
|
||
pane_size: (800.0, 600.0),
|
||
render_generation: 0,
|
||
dirty_region: std::cell::RefCell::new(None),
|
||
full_upload: std::cell::RefCell::new(true),
|
||
selection_transform: None,
|
||
transform_drag_handle: TransformHandle::None,
|
||
transform_drag_start: None,
|
||
transform_original: None,
|
||
transform_source: None,
|
||
transform_layer_baseline: None,
|
||
transform_selection_baseline: None,
|
||
transform_preview_base: None,
|
||
internal_clipboard: None,
|
||
pending_paste: None,
|
||
pending_paste_as_new_layer: false,
|
||
selection_mask: None,
|
||
selection_texture: None,
|
||
selection_model_dirty: false,
|
||
selection_mask_dirty: std::cell::Cell::new(false),
|
||
selection_bounds: None,
|
||
selection_history: Vec::new(),
|
||
selection_history_marker: 0,
|
||
crop_state: CropState::default(),
|
||
lasso_points: Vec::new(),
|
||
polygon_points: Vec::new(),
|
||
gradient_drag: None,
|
||
vision_rect: None,
|
||
text_draft: None,
|
||
selection_mode: selection::SelectionMode::Replace,
|
||
saved_selection_mask: None,
|
||
quick_mask: false,
|
||
selected_vector_shape: None,
|
||
vector_cycle_index: 0,
|
||
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
|
||
vector_drag_preview: None,
|
||
rotation_snap_active: false,
|
||
rotation_snap_angle: 0.0,
|
||
resize_snap_active: false,
|
||
layer_thumbnails: std::collections::HashMap::new(),
|
||
pending_thumbnail_layers: std::collections::HashSet::new(),
|
||
thumb_gen: 0,
|
||
};
|
||
|
||
// Load saved settings
|
||
let settings = crate::settings::AppSettings::load();
|
||
|
||
let mut app = Self {
|
||
documents: vec![doc],
|
||
show_welcome: false,
|
||
active_doc: 0,
|
||
tool_state: ToolState::default(),
|
||
fg_color: [0, 0, 0, 255],
|
||
bg_color: [255, 255, 255, 255],
|
||
last_cursor_pos: None,
|
||
canvas_cursor_pos: None,
|
||
active_dialog: ActiveDialog::None,
|
||
active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush)
|
||
.unwrap_or(0),
|
||
open_subtool_slot: None,
|
||
sidebar_expanded: settings.panel_layout.sidebar_expanded,
|
||
slot_last_used: vec![0; 21],
|
||
_dialog_input: String::new(),
|
||
_dialog_input2: String::new(),
|
||
_show_performance: false,
|
||
_ui_scale: 1.0,
|
||
modifiers: iced::keyboard::Modifiers::default(),
|
||
canvas_context_menu: None,
|
||
settings: settings.clone(),
|
||
window_id: None,
|
||
screenshot_request,
|
||
dialog_new_name: "Untitled".to_string(),
|
||
dialog_new_width: 800,
|
||
dialog_new_height: 600,
|
||
dialog_new_transparent: true,
|
||
dialog_brightness: 0.0,
|
||
dialog_contrast: 0.0,
|
||
dialog_hue: 0.0,
|
||
dialog_saturation: 0.0,
|
||
dialog_lightness: 0.0,
|
||
dialog_selection_value: 5.0,
|
||
dialog_image_size_width: 800,
|
||
dialog_image_size_height: 600,
|
||
dialog_image_size_constrain: true,
|
||
dialog_image_size_original_ratio: 800.0 / 600.0,
|
||
dialog_canvas_size_width: 800,
|
||
dialog_canvas_size_height: 600,
|
||
dialog_canvas_size_anchor: (1, 1),
|
||
selected_filter: None,
|
||
filter_params: serde_json::json!({}),
|
||
filter_preview_active: false,
|
||
filter_categories_expanded: std::collections::HashMap::new(),
|
||
preview_baseline: None,
|
||
layer_style_baseline: None,
|
||
pending_file_operation: None,
|
||
pending_document_close: None,
|
||
dock: settings.panel_layout.restore_dock(),
|
||
tablet_state: Arc::new(Mutex::new(TabletState::new())),
|
||
initialized: false,
|
||
theme_state: ThemeState::new(settings.theme_preset),
|
||
active_menu: None,
|
||
recent_files: load_recent_files(),
|
||
recent_colors: Vec::new(),
|
||
color_picker_target: None,
|
||
popup_color: [0, 0, 0, 255],
|
||
popup_color_hex: "000000FF".to_string(),
|
||
popup_color_tab: 1,
|
||
selected_style: 0,
|
||
show_style_color_picker: false,
|
||
style_color_picker_idx: 0,
|
||
style_color_hsl: (0.0, 0.0, 0.0),
|
||
style_color_is_shadow: false,
|
||
color_tab: 0,
|
||
layer_style_offset: (0.0, 0.0),
|
||
layer_style_dragging: false,
|
||
layer_style_drag_start: None,
|
||
marching_ants_offset: 0.0,
|
||
ai_chat_state: AiChatState::default(),
|
||
ai_assistant_tab: 1, // default to AI Chat tab (EGUI defaults to ComfyUI tab 0)
|
||
comfy_state: ComfyUiState::default(),
|
||
title_search: String::new(),
|
||
show_panel_list: false,
|
||
ai_chat_abort: None,
|
||
script_text: crate::script::parser::DEFAULT_SCRIPT.to_string(),
|
||
script_content: iced::widget::text_editor::Content::with_text(
|
||
crate::script::parser::DEFAULT_SCRIPT,
|
||
),
|
||
script_error: None,
|
||
script_error_line: None,
|
||
script_is_running: false,
|
||
ai_script_prompt: String::new(),
|
||
ai_script_output: String::new(),
|
||
ai_script_error: None,
|
||
ai_script_notice: None,
|
||
ai_script_is_running: false,
|
||
ai_script_provider: "ollama".to_string(),
|
||
ai_script_model: "qwen2.5:7b".to_string(),
|
||
ai_script_url: "http://localhost:11434".to_string(),
|
||
viewer_active: false,
|
||
viewer_state: crate::viewer::ViewerState::default(),
|
||
active_dock_profile: None,
|
||
dock_profile_new_name: String::new(),
|
||
dock_profile_rename_idx: None,
|
||
dock_profile_rename_buf: String::new(),
|
||
show_dock_profile_dialog: false,
|
||
_language: i18n::Language::default(),
|
||
brush_category: crate::panels::brushes::BrushCategory::All,
|
||
runtime_version: crate::build_info::runtime_build_version(),
|
||
colors_dirty: false,
|
||
color_dragging: false,
|
||
pending_drag_color: None,
|
||
vector_shapes_snapshot: None,
|
||
cached_custom_shapes: Vec::new(),
|
||
vector_drag_last: None,
|
||
vector_drag_last_bounds: None,
|
||
vector_drag_last_angle: None,
|
||
vector_drag_session: None,
|
||
bool_shape_a: None,
|
||
bool_shape_b: None,
|
||
plugin_registry: {
|
||
let mut reg = crate::plugins::registry::PluginRegistry::new();
|
||
reg.register(Box::new(
|
||
crate::plugins::built_in::debug_logger::DebugSignalLogger::new(),
|
||
));
|
||
reg
|
||
},
|
||
svg_editor_state: None,
|
||
save_error_state: None,
|
||
};
|
||
|
||
if let Some(panel) = app
|
||
.screenshot_request
|
||
.as_ref()
|
||
.and_then(|request| request.panel.as_deref())
|
||
.and_then(crate::dock::state::PaneType::from_label)
|
||
{
|
||
app.dock.reopen_pane(panel);
|
||
}
|
||
if let Some(panel) = app
|
||
.screenshot_request
|
||
.as_ref()
|
||
.and_then(|request| request.floating_panel.as_deref())
|
||
.and_then(crate::dock::state::PaneType::from_label)
|
||
{
|
||
app.dock.reopen_pane(panel);
|
||
if let Some(pane) = app.dock.pane_for_type(panel) {
|
||
app.dock
|
||
.float_pane(pane, (settings.window.width, settings.window.height));
|
||
}
|
||
}
|
||
if let Some(panel) = app
|
||
.screenshot_request
|
||
.as_ref()
|
||
.and_then(|request| request.auto_hide_panel.as_deref())
|
||
.and_then(crate::dock::state::PaneType::from_label)
|
||
{
|
||
app.dock.reopen_pane(panel);
|
||
if let Some(pane) = app.dock.pane_for_type(panel) {
|
||
app.dock
|
||
.auto_hide_pane(pane, Some(crate::dock::manager::DockEdge::Right));
|
||
}
|
||
}
|
||
if app
|
||
.screenshot_request
|
||
.as_ref()
|
||
.is_some_and(|request| request.welcome)
|
||
{
|
||
app.show_welcome = true;
|
||
}
|
||
if app
|
||
.screenshot_request
|
||
.as_ref()
|
||
.is_some_and(|request| request.svg_editor)
|
||
{
|
||
let fixture = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M 8,50 L 58,50 L 58,22 L 94,50 L 58,78 L 58,62 L 8,62 Z"/></svg>"#;
|
||
if let Ok(editable) = crate::panels::svg_editor::parse_editable(fixture) {
|
||
let mut editor = crate::panels::svg_editor::SvgEditorState {
|
||
layer_id: 0,
|
||
shape_idx: 0,
|
||
original_svg: fixture.to_string(),
|
||
editable,
|
||
selected_poly: 0,
|
||
selected_node: None,
|
||
zoom: 1.0,
|
||
pan: iced::Vector::ZERO,
|
||
x_input: String::new(),
|
||
y_input: String::new(),
|
||
snap_to_grid: true,
|
||
grid_size: 5.0,
|
||
grid_input: "5".to_string(),
|
||
};
|
||
editor.select_node(0, 3);
|
||
app.svg_editor_state = Some(editor);
|
||
app.active_dialog = ActiveDialog::SvgEditor;
|
||
}
|
||
}
|
||
|
||
// Apply saved settings to tool state
|
||
settings.apply_to_tool_state(&mut app.tool_state);
|
||
|
||
// Restore persisted colors (fg/bg/recent) from the colors file, mirroring
|
||
// egui's `hcie_colors` JSON persistence in app/mod.rs:380-386.
|
||
let colors = load_colors();
|
||
if colors.fg.is_some() {
|
||
app.fg_color = colors.fg.unwrap();
|
||
}
|
||
if colors.bg.is_some() {
|
||
app.bg_color = colors.bg.unwrap();
|
||
}
|
||
if !colors.recent.is_empty() {
|
||
app.recent_colors = colors.recent;
|
||
}
|
||
let fg = app.fg_color;
|
||
app.active_document_mut().engine.set_color(fg);
|
||
app.settings = settings;
|
||
|
||
// If a file path was provided, open it (route multi-layer formats to dedicated importers)
|
||
if let Some(path) = load_path {
|
||
let path_str = path.to_string_lossy().to_string();
|
||
let ext = path
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.unwrap_or("")
|
||
.to_lowercase();
|
||
let result = match ext.as_str() {
|
||
"psd" => app.documents[0].engine.import_psd(&path_str),
|
||
"kra" => app.documents[0].engine.import_kra(&path_str),
|
||
"hcie" => app.documents[0].engine.load_native(&path_str),
|
||
_ => app.documents[0].engine.open_image(&path_str),
|
||
};
|
||
match result {
|
||
Ok(()) => {
|
||
app.documents[0].engine.pre_tile_all_layers();
|
||
app.documents[0].name = path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| "Untitled".to_string());
|
||
app.documents[0].source_path = Some(path.clone());
|
||
app.add_recent_file(&path);
|
||
let composite = app.documents[0].engine.get_composite_pixels();
|
||
let size = composite.len();
|
||
log::info!(
|
||
"[startup] composite pixels: {} bytes ({}x{})",
|
||
size,
|
||
app.documents[0].engine.canvas_width(),
|
||
app.documents[0].engine.canvas_height()
|
||
);
|
||
app.documents[0].composite_raw = composite;
|
||
app.documents[0]
|
||
.composite_pixels
|
||
.replace(&app.documents[0].composite_raw);
|
||
let selection = app.documents[0]
|
||
.engine
|
||
.get_selection_mask()
|
||
.map(ToOwned::to_owned);
|
||
set_document_selection_mask(&mut app.documents[0], selection);
|
||
app.documents[0].full_upload.replace(true);
|
||
app.documents[0].render_generation =
|
||
app.documents[0].render_generation.wrapping_add(1);
|
||
app.documents[0].cached_layers = app.documents[0].engine.layer_infos();
|
||
let history_len = app.documents[0].engine.history_len();
|
||
app.documents[0].cached_history = (0..history_len)
|
||
.filter_map(|i| {
|
||
app.documents[0]
|
||
.engine
|
||
.history_description(i)
|
||
.map(|d| (i, d))
|
||
})
|
||
.collect();
|
||
app.documents[0].history_current = app.documents[0].engine.history_current();
|
||
}
|
||
Err(e) => {
|
||
log::error!("Failed to open file on startup: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
app.initialized = true;
|
||
let init_task = iced::window::get_oldest().map(|opt_id| {
|
||
if let Some(id) = opt_id {
|
||
Message::WindowId(id)
|
||
} else {
|
||
Message::NoOp
|
||
}
|
||
});
|
||
(app, init_task)
|
||
}
|
||
|
||
/// Return a mutable reference to the active document.
|
||
fn active_document_mut(&mut self) -> &mut IcedDocument {
|
||
self.documents
|
||
.get_mut(self.active_doc)
|
||
.expect("no active document")
|
||
}
|
||
|
||
/// Convert pane-relative coordinates to canvas-space coordinates.
|
||
///
|
||
/// The canvas image is centered in the pane, with `pan_offset` as an
|
||
/// additional offset from center. This function accounts for both.
|
||
fn _screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
|
||
let doc = &self.documents[self.active_doc];
|
||
let zoom = doc.zoom;
|
||
let (pane_w, pane_h) = doc.pane_size;
|
||
let engine_w = doc.engine.canvas_width() as f32;
|
||
let engine_h = doc.engine.canvas_height() as f32;
|
||
let display_w = engine_w * zoom;
|
||
let display_h = engine_h * zoom;
|
||
|
||
// Canvas image position = center of pane + pan_offset
|
||
let canvas_origin_x = (pane_w - display_w) / 2.0 + doc.pan_offset.x;
|
||
let canvas_origin_y = (pane_h - display_h) / 2.0 + doc.pan_offset.y;
|
||
|
||
// Convert from pane-space to canvas-space
|
||
let canvas_x = (sx - canvas_origin_x) / zoom;
|
||
let canvas_y = (sy - canvas_origin_y) / zoom;
|
||
|
||
if canvas_x >= 0.0 && canvas_x < engine_w && canvas_y >= 0.0 && canvas_y < engine_h {
|
||
Some((canvas_x, canvas_y))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Hit-test which vector edit handle is at the given canvas-space position.
|
||
///
|
||
/// Checks rotation handle first (circle area), then resize handles (square
|
||
/// hit-box), then the move handle (inside bounding box). Returns the
|
||
/// matching `VectorEditHandle` variant. Coordinates are in canvas-space.
|
||
fn vector_hit_test_handle(&self, cx: f32, cy: f32) -> hcie_engine_api::VectorEditHandle {
|
||
use hcie_engine_api::VectorEditHandle as VEH;
|
||
|
||
let doc = &self.documents[self.active_doc];
|
||
let Some(idx) = doc.selected_vector_shape else {
|
||
return VEH::None;
|
||
};
|
||
let layer_id = doc.engine.active_layer_id();
|
||
let shapes = match doc.engine.active_vector_shapes() {
|
||
Some(s) => s,
|
||
None => return VEH::None,
|
||
};
|
||
let shape = match shapes.get(idx) {
|
||
Some(s) => s,
|
||
None => return VEH::None,
|
||
};
|
||
|
||
let bounds = shape.normalized_bounds();
|
||
let angle = doc.engine.vector_shape_angle(layer_id, idx);
|
||
crate::vector_edit::hit_test_handle(bounds, angle, (cx, cy), doc.zoom)
|
||
}
|
||
|
||
/// Add a file to the recent files list (most recent first, max 20) and persist to disk.
|
||
fn add_recent_file(&mut self, path: &std::path::Path) {
|
||
let path_str = path.to_string_lossy().to_string();
|
||
let name = path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| path_str.clone());
|
||
// Remove duplicates
|
||
self.recent_files.retain(|e| e.path != path_str);
|
||
// Insert at front
|
||
self.recent_files.insert(
|
||
0,
|
||
RecentFileEntry {
|
||
path: path_str,
|
||
name,
|
||
},
|
||
);
|
||
// Cap at 20
|
||
self.recent_files.truncate(20);
|
||
// Persist to disk
|
||
save_recent_files(&self.recent_files);
|
||
}
|
||
|
||
/// Captures the active layer once so repeated previews always start from identical pixels.
|
||
fn begin_preview(&mut self) -> bool {
|
||
self.restore_preview();
|
||
let document_index = self.active_doc;
|
||
let layer_id = self.documents[document_index].engine.active_layer_id();
|
||
let Some(pixels) = self.documents[document_index]
|
||
.engine
|
||
.get_layer_pixels(layer_id)
|
||
else {
|
||
return false;
|
||
};
|
||
self.preview_baseline = Some(PreviewBaseline {
|
||
document_index,
|
||
layer_id,
|
||
pixels,
|
||
});
|
||
true
|
||
}
|
||
|
||
/// Restores preview pixels without consuming the immutable baseline.
|
||
fn reset_preview_to_baseline(&mut self) -> bool {
|
||
let Some(baseline) = self.preview_baseline.as_ref() else {
|
||
return false;
|
||
};
|
||
if baseline.document_index >= self.documents.len() {
|
||
return false;
|
||
}
|
||
self.documents[baseline.document_index]
|
||
.engine
|
||
.set_layer_pixels(baseline.layer_id, baseline.pixels.clone());
|
||
true
|
||
}
|
||
|
||
/// Restores preview pixels and ends the current preview session.
|
||
fn restore_preview(&mut self) -> bool {
|
||
let restored = self.reset_preview_to_baseline();
|
||
self.preview_baseline = None;
|
||
restored
|
||
}
|
||
|
||
/// Build a writable fallback path when a save fails — `~/<stem>_copy.<ext>`
|
||
/// (home dir, else temp dir). Mirrors EGUI's `utils.rs:suggest_alternative_path`.
|
||
fn suggest_alternative_path(original: &std::path::Path) -> std::path::PathBuf {
|
||
let stem = original
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("untitled");
|
||
let ext = original
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.unwrap_or("hcie");
|
||
let file_name = format!("{}_copy.{}", stem, ext);
|
||
if let Some(home) = dirs::home_dir() {
|
||
let candidate = home.join(&file_name);
|
||
if candidate.parent().map(|p| p.exists()).unwrap_or(false) {
|
||
return candidate;
|
||
}
|
||
}
|
||
std::env::temp_dir().join(file_name)
|
||
}
|
||
|
||
/// Saves a document using extension-based engine API routing.
|
||
fn save_document_to(
|
||
&self,
|
||
document_index: usize,
|
||
path: &std::path::Path,
|
||
) -> Result<(), String> {
|
||
let path_str = path.to_string_lossy();
|
||
let extension = path
|
||
.extension()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or("")
|
||
.to_ascii_lowercase();
|
||
if extension.is_empty() {
|
||
return Err("Save As requires a supported file extension".to_string());
|
||
}
|
||
let engine = &self.documents[document_index].engine;
|
||
match extension.as_str() {
|
||
"hcie" => engine.save_native(&path_str),
|
||
"psd" => engine.export_psd(&path_str),
|
||
"kra" => engine.export_kra(&path_str),
|
||
"png" | "jpg" | "jpeg" | "webp" | "avif" | "bmp" | "gif" | "tiff" | "tif" | "tga"
|
||
| "exr" => engine.save_as(&path_str, &extension),
|
||
_ => Err(format!("Unsupported save extension: {extension}")),
|
||
}
|
||
}
|
||
|
||
/// Applies successful-save identity updates and records the path as recent.
|
||
fn finish_successful_save(&mut self, document_index: usize, path: std::path::PathBuf) {
|
||
let name = path
|
||
.file_name()
|
||
.map(|value| value.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| "Untitled".to_string());
|
||
let doc = &mut self.documents[document_index];
|
||
doc.source_path = Some(path.clone());
|
||
doc.name = name;
|
||
doc.modified = false;
|
||
self.add_recent_file(&path);
|
||
}
|
||
|
||
/// Continues window-close processing after one document has saved successfully.
|
||
fn continue_close_after_save(&mut self) -> Task<Message> {
|
||
if let Some(document_index) = self.pending_document_close.take() {
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.close_document_tab(document_index);
|
||
return Task::none();
|
||
}
|
||
if let Some(next) = self.documents.iter().position(|document| document.modified) {
|
||
self.active_doc = next;
|
||
self.documents[next].full_upload.replace(true);
|
||
self.documents[next].selection_mask_dirty.set(true);
|
||
self.active_dialog = ActiveDialog::CloseConfirm;
|
||
Task::none()
|
||
} else {
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.window_id.map_or_else(Task::none, iced::window::close)
|
||
}
|
||
}
|
||
|
||
/// Removes one document tab and keeps the editor shell alive for the final tab.
|
||
///
|
||
/// Arguments: `document_index` is the tab to remove. Returns: Nothing. Logic & Workflow:
|
||
/// indices before the active tab shift it left; closing the active tab selects the tab now at
|
||
/// that position or the preceding final tab. Side Effects: Drops the document and edit state.
|
||
fn close_document_tab(&mut self, document_index: usize) {
|
||
if document_index >= self.documents.len() {
|
||
return;
|
||
}
|
||
if self.documents.len() == 1 {
|
||
let _ = self.update(Message::NewDocument(800, 600));
|
||
self.documents.remove(document_index);
|
||
self.active_doc = 0;
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.show_welcome = true;
|
||
self.pending_document_close = None;
|
||
self.vector_shapes_snapshot = None;
|
||
self.vector_drag_session = None;
|
||
self.vector_drag_last = None;
|
||
self.vector_drag_last_bounds = None;
|
||
self.vector_drag_last_angle = None;
|
||
if let Some(doc) = self.documents.get_mut(self.active_doc) {
|
||
doc.vector_drag_preview = None;
|
||
doc.full_upload.replace(true);
|
||
doc.selection_mask_dirty.set(true);
|
||
}
|
||
return;
|
||
}
|
||
self.documents.remove(document_index);
|
||
self.active_doc = active_index_after_document_close(
|
||
self.active_doc,
|
||
document_index,
|
||
self.documents.len(),
|
||
);
|
||
self.vector_shapes_snapshot = None;
|
||
self.vector_drag_session = None;
|
||
self.vector_drag_last = None;
|
||
if let Some(doc) = self.documents.get_mut(self.active_doc) {
|
||
doc.vector_drag_preview = None;
|
||
doc.full_upload.replace(true);
|
||
doc.selection_mask_dirty.set(true);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Returns true if `color` is within the proximity threshold of `last`,
|
||
/// meaning it is visually very similar and should be skipped to prevent
|
||
/// slider/wheel drag noise from flooding the recent colors list.
|
||
///
|
||
/// The threshold is ≤2 units per RGBA channel (unsigned comparison).
|
||
pub fn is_proximate_to_last(last: &[u8; 4], color: &[u8; 4]) -> bool {
|
||
let channel_diff = |a: u8, b: u8| -> u16 { (a as i16 - b as i16).unsigned_abs() as u16 };
|
||
channel_diff(last[0], color[0]) <= 2
|
||
&& channel_diff(last[1], color[1]) <= 2
|
||
&& channel_diff(last[2], color[2]) <= 2
|
||
}
|
||
|
||
impl HcieIcedApp {
|
||
/// Add a color to the recent colors list (most recent first, max 40).
|
||
///
|
||
/// Immediately persists to disk so committed colors survive a crash or non-graceful
|
||
/// shutdown. Color-wheel movement never calls this method; the final sampled color is
|
||
/// committed once by `ColorDragEnded`.
|
||
///
|
||
/// Proximity dedup: if the new color is within the proximity threshold of the most recent
|
||
/// entry — checked by `is_proximate_to_last` — it is skipped. This prevents slider and
|
||
/// wheel drag noise from flooding the recent colors list with near-identical entries.
|
||
fn add_recent_color(&mut self, color: [u8; 4]) {
|
||
// Skip if visually very similar to the most recent entry (proximity dedup).
|
||
if let Some(last) = self.recent_colors.first() {
|
||
if is_proximate_to_last(last, &color) {
|
||
return;
|
||
}
|
||
}
|
||
self.recent_colors.retain(|c| *c != color);
|
||
self.recent_colors.insert(0, color);
|
||
self.recent_colors.truncate(40);
|
||
// Write immediately unless we are in the middle of a colour-wheel
|
||
// drag (where callers are responsible for the final flush).
|
||
if !self.color_dragging {
|
||
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
|
||
}
|
||
}
|
||
|
||
/// Refresh the canvas using incremental dirty-region compositing.
|
||
///
|
||
/// Uses `render_composite_region()` which only re-composites dirty tiles.
|
||
/// Only the dirty region is copied from the engine's scratch buffer into
|
||
/// the local composite buffer, avoiding a full 33 MB copy for small strokes.
|
||
fn refresh_canvas_if_needed(&mut self) {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
|
||
if doc.engine.is_composite_dirty() {
|
||
let refresh_start = std::time::Instant::now();
|
||
for layer in &doc.cached_layers {
|
||
if doc.engine.is_layer_dirty(layer.id) {
|
||
doc.pending_thumbnail_layers.insert(layer.id);
|
||
}
|
||
}
|
||
let composite_start = std::time::Instant::now();
|
||
let (dirty_rect, ptr, len) = doc.engine.render_composite_region();
|
||
crate::canvas::perf::record_duration(
|
||
"render_composite_region",
|
||
composite_start.elapsed(),
|
||
);
|
||
if len > 0 && !ptr.is_null() {
|
||
let staging_start = std::time::Instant::now();
|
||
let mut is_full_copy = false;
|
||
// Resize composite_raw if canvas dimensions changed (e.g. after PSD import)
|
||
if doc.composite_raw.len() != len {
|
||
doc.composite_raw.resize(len, 0);
|
||
doc.full_upload.replace(true);
|
||
is_full_copy = true;
|
||
}
|
||
|
||
// If the canvas changed size, we MUST force a full upload because the old memory
|
||
// structure is no longer valid, and a partial copy would leave zeros in the new buffer.
|
||
if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) {
|
||
crate::canvas::perf::record_dirty_region(rect);
|
||
let w = doc.engine.canvas_width() as usize;
|
||
let [x0, y0, x1, y1] = rect;
|
||
let x0 = x0 as usize;
|
||
let y0 = y0 as usize;
|
||
let x1 = x1 as usize;
|
||
let y1 = y1 as usize;
|
||
|
||
let row_bytes = (x1 - x0) * 4;
|
||
if row_bytes > 0 {
|
||
unsafe {
|
||
let dst_raw_ptr = doc.composite_raw.as_mut_ptr();
|
||
for y in y0..y1 {
|
||
let offset = (y * w + x0) * 4;
|
||
std::ptr::copy_nonoverlapping(
|
||
ptr.add(offset),
|
||
dst_raw_ptr.add(offset),
|
||
row_bytes,
|
||
);
|
||
}
|
||
}
|
||
if let Err(error) = doc.composite_pixels.copy_region_from(
|
||
&doc.composite_raw,
|
||
w as u32,
|
||
rect,
|
||
) {
|
||
log::error!("failed to stage dirty composite region: {error}");
|
||
doc.composite_pixels.replace(&doc.composite_raw);
|
||
doc.full_upload.replace(true);
|
||
}
|
||
}
|
||
|
||
let current = doc.dirty_region.replace(None);
|
||
doc.dirty_region.replace(Some(union_regions(current, rect)));
|
||
} else {
|
||
// Full update
|
||
crate::canvas::perf::record_dirty_region([
|
||
0,
|
||
0,
|
||
doc.engine.canvas_width(),
|
||
doc.engine.canvas_height(),
|
||
]);
|
||
unsafe {
|
||
std::ptr::copy_nonoverlapping(ptr, doc.composite_raw.as_mut_ptr(), len);
|
||
}
|
||
doc.composite_pixels.replace(&doc.composite_raw);
|
||
doc.full_upload.replace(true);
|
||
doc.dirty_region.replace(None);
|
||
}
|
||
|
||
doc.render_generation = doc.render_generation.wrapping_add(1);
|
||
crate::canvas::perf::record_duration("cpu_staging", staging_start.elapsed());
|
||
}
|
||
|
||
doc.engine.clear_dirty_flags();
|
||
crate::canvas::perf::record_duration("canvas_refresh", refresh_start.elapsed());
|
||
}
|
||
|
||
sync_document_selection_if_needed(doc);
|
||
}
|
||
|
||
/// Refresh panel caches after canvas work has completed.
|
||
///
|
||
/// Layer pixel copies and history description allocation are intentionally
|
||
/// excluded from the active-stroke path and run only for completed document
|
||
/// operations or stroke release.
|
||
fn refresh_panel_caches(&mut self) {
|
||
let refresh_start = std::time::Instant::now();
|
||
let doc = &mut self.documents[self.active_doc];
|
||
|
||
doc.cached_layers = doc.engine.layer_infos();
|
||
let max_dim = 48u32;
|
||
doc.layer_thumbnails
|
||
.retain(|id, _| doc.cached_layers.iter().any(|l| l.id == *id));
|
||
let mut thumbnails_changed = false;
|
||
for info in &doc.cached_layers {
|
||
if info.width == 0 || info.height == 0 {
|
||
continue;
|
||
}
|
||
let cached = doc.layer_thumbnails.contains_key(&info.id);
|
||
let dirty = doc.pending_thumbnail_layers.contains(&info.id)
|
||
|| doc.engine.is_layer_dirty(info.id);
|
||
if cached && !dirty {
|
||
continue;
|
||
}
|
||
if let Some(px) = doc.engine.get_layer_pixels(info.id) {
|
||
if px.len() >= (info.width * info.height * 4) as usize {
|
||
let aspect = info.height as f32 / info.width as f32;
|
||
let th = ((max_dim as f32) * aspect).round().max(1.0) as u32;
|
||
let thumb = crate::panels::thumbnails::generate_thumbnail(
|
||
&px,
|
||
info.width,
|
||
info.height,
|
||
max_dim,
|
||
);
|
||
doc.layer_thumbnails.insert(info.id, (max_dim, th, thumb));
|
||
doc.pending_thumbnail_layers.remove(&info.id);
|
||
thumbnails_changed = true;
|
||
}
|
||
}
|
||
}
|
||
if thumbnails_changed {
|
||
doc.thumb_gen = doc.thumb_gen.wrapping_add(1);
|
||
}
|
||
let history_len = doc.engine.history_len();
|
||
doc.cached_history = (0..history_len)
|
||
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
|
||
.collect();
|
||
doc.history_current = doc.engine.history_current();
|
||
crate::canvas::perf::record_duration("panel_cache_refresh", refresh_start.elapsed());
|
||
}
|
||
|
||
/// Refresh both the visible canvas and the document panel caches.
|
||
///
|
||
/// This preserves the existing behavior for non-interactive operations while
|
||
/// allowing active drawing to call `refresh_canvas_if_needed()` directly.
|
||
fn refresh_composite_if_needed(&mut self) {
|
||
self.refresh_canvas_if_needed();
|
||
self.refresh_panel_caches();
|
||
}
|
||
|
||
/// Opens a file in a newly allocated editor tab.
|
||
///
|
||
/// **Purpose:** Prevents file-open entry points from replacing the active
|
||
/// document engine. **Logic & Workflow:** Creates a disposable 1x1 document,
|
||
/// loads the selected path into its engine, activates and initializes it on
|
||
/// success, or removes it and restores the previous tab on failure.
|
||
/// **Arguments:** `path` is the file to load and `add_to_recent` controls
|
||
/// recent-file persistence. **Returns:** The new document index or an engine
|
||
/// error string. **Side Effects / Dependencies:** Reads the file, appends or
|
||
/// removes one document, updates GPU upload caches, and may persist recents.
|
||
fn open_path_in_new_tab(
|
||
&mut self,
|
||
path: std::path::PathBuf,
|
||
add_to_recent: bool,
|
||
) -> Result<usize, String> {
|
||
let previous_active = self.active_doc;
|
||
let previous_count = self.documents.len();
|
||
let _ = self.update(Message::NewDocument(1, 1));
|
||
let document_index = self.documents.len() - 1;
|
||
|
||
if let Err(error) = load_path_into_engine(&mut self.documents[document_index].engine, &path)
|
||
{
|
||
self.documents.remove(document_index);
|
||
self.active_doc = previous_active.min(self.documents.len().saturating_sub(1));
|
||
debug_assert_eq!(self.documents.len(), previous_count);
|
||
return Err(error);
|
||
}
|
||
|
||
self.show_welcome = false;
|
||
self.active_doc = document_index;
|
||
let document = &mut self.documents[document_index];
|
||
document.engine.pre_tile_all_layers();
|
||
document.selection_model_dirty = true;
|
||
document.name = path
|
||
.file_name()
|
||
.map(|name| name.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| "Untitled".to_string());
|
||
document.source_path = Some(path.clone());
|
||
document.modified = false;
|
||
document.composite_raw = document.engine.get_composite_pixels();
|
||
document.composite_pixels.replace(&document.composite_raw);
|
||
document.render_generation = document.render_generation.wrapping_add(1);
|
||
document.full_upload.replace(true);
|
||
|
||
if add_to_recent {
|
||
self.add_recent_file(&path);
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
Ok(document_index)
|
||
}
|
||
|
||
/// Apply brush parameters from tool state to the engine.
|
||
///
|
||
/// Called before starting a stroke to ensure the engine uses
|
||
/// the current brush size, opacity, hardness, style, and cyclic color.
|
||
fn apply_brush_params(&mut self) {
|
||
let mut tip = self
|
||
.tool_state
|
||
.active_brush_preset
|
||
.as_ref()
|
||
.map(|preset| protocol_tip_from_preset(&preset.tip))
|
||
.unwrap_or_else(|| build_brush_tip(&self.tool_state));
|
||
tip.style = self.tool_state.brush_style;
|
||
tip.size = self.tool_state.brush_size;
|
||
tip.opacity = self.tool_state.brush_opacity;
|
||
tip.hardness = self.tool_state.brush_hardness;
|
||
tip.spacing = self.tool_state.brush_spacing;
|
||
// Cyclic color is handled by engine state, not the tip, but preview rendering
|
||
// also reads the tip's time dynamics. Synchronize both here.
|
||
tip.time_enabled = self.tool_state.brush_cyclic_color;
|
||
tip.time_size_start = 1.0;
|
||
tip.time_size_end = 1.0;
|
||
tip.time_opacity_start = 1.0;
|
||
tip.time_opacity_end = 1.0;
|
||
tip.time_angle_start = 0.0;
|
||
tip.time_angle_end = 0.0;
|
||
match self.tool_state.active_tool {
|
||
Tool::Pen => {
|
||
tip.style = BrushStyle::Round;
|
||
tip.size = self.settings.tool_settings.pen_size;
|
||
tip.spacing = 0.05;
|
||
}
|
||
Tool::Spray => {
|
||
tip.style = BrushStyle::Spray;
|
||
tip.size = self.settings.tool_settings.spray_radius;
|
||
tip.spray_particle_size = self.settings.tool_settings.spray_particle_size;
|
||
tip.spray_density = self.settings.tool_settings.spray_density;
|
||
tip.spacing = 0.5;
|
||
}
|
||
_ => {}
|
||
}
|
||
let engine = &mut self.documents[self.active_doc].engine;
|
||
engine.set_brush_tip(tip);
|
||
engine.set_cyclic_color(self.tool_state.brush_cyclic_color);
|
||
engine.set_cyclic_speed(self.tool_state.brush_cyclic_speed);
|
||
}
|
||
|
||
/// Push the current SVG editor model into the active vector shape so the
|
||
/// main canvas reflects node edits immediately. The public engine API marks
|
||
/// the vector layer dirty but does not create a history snapshot here;
|
||
/// this is intentionally a live-preview operation.
|
||
fn sync_svg_editor_preview(&mut self) {
|
||
let Some((layer_id, shape_idx, svg)) = self
|
||
.svg_editor_state
|
||
.as_ref()
|
||
.map(|state| (state.layer_id, state.shape_idx, state.editable.to_svg()))
|
||
else {
|
||
return;
|
||
};
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_svg(layer_id, shape_idx, &svg);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
|
||
// ── Update ──────────────────────────────────────────
|
||
|
||
/// Handle a message and return an optional command.
|
||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||
let frame_start = std::time::Instant::now();
|
||
let since_last = frame_start.duration_since(self.tool_state.last_update_instant);
|
||
self.tool_state.last_update_instant = frame_start;
|
||
|
||
// Update marching ants animation offset (cycles at ~60fps for smooth animation)
|
||
// The offset cycles from 0.0 to 1.0, completing one cycle every ~1 second
|
||
if self
|
||
.documents
|
||
.iter()
|
||
.any(|doc| doc.selection_bounds.is_some())
|
||
{
|
||
self.marching_ants_offset =
|
||
(self.marching_ants_offset + since_last.as_secs_f32() * 0.5) % 1.0;
|
||
}
|
||
|
||
// Dispatch relevant messages to the plugin system before the match consumes `message`
|
||
let _plugin_actions = crate::plugins::integration::try_dispatch(
|
||
&mut self.plugin_registry,
|
||
&message,
|
||
&mut self.tool_state,
|
||
);
|
||
|
||
match message {
|
||
Message::ToolSelected(tool) => {
|
||
log::info!("Tool selected: {:?}", tool);
|
||
self.tool_state.active_tool = tool;
|
||
if tool == Tool::Brush {
|
||
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
||
}
|
||
// When the user picks a custom shape, reveal the Custom Shapes
|
||
// panel and keep the active tool slot on the vector-shapes slot
|
||
// so the sidebar highlight stays coherent.
|
||
if matches!(tool, Tool::CustomShape(_)) {
|
||
self.dock
|
||
.reopen_pane(crate::dock::state::PaneType::CustomShapes);
|
||
if let Some(slot_idx) = crate::sidebar::slot_for_tool(tool) {
|
||
self.active_tool_slot = slot_idx;
|
||
}
|
||
}
|
||
// Record this tool as the last-used one for its slot, so the
|
||
// sidebar shows the last-used tool's icon next time (egui's
|
||
// slot_last_used behavior at tool_state.rs:278-286).
|
||
if let Some(slot_idx) = crate::sidebar::slot_for_tool(tool) {
|
||
self.active_tool_slot = slot_idx;
|
||
if let Some(slot) = crate::sidebar::tool_slots().get(slot_idx) {
|
||
if let Some(tidx) = slot.tools.iter().position(|&t| t == tool) {
|
||
if slot_idx < self.slot_last_used.len() {
|
||
self.slot_last_used[slot_idx] = tidx;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
|
||
Message::ToolSlotClicked(slot_idx, primary_tool) => {
|
||
let same_slot = self.active_tool_slot == slot_idx;
|
||
self.active_tool_slot = slot_idx;
|
||
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
|
||
let selected_tool = if crate::sidebar::tool_slots().get(slot_idx).is_some() {
|
||
tool
|
||
} else {
|
||
primary_tool
|
||
};
|
||
self.tool_state.active_tool = selected_tool;
|
||
if selected_tool == Tool::Brush {
|
||
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
||
}
|
||
let supports_popup = crate::sidebar::tool_slots()
|
||
.get(slot_idx)
|
||
.is_some_and(|slot| slot.tools.len() > 1);
|
||
if same_slot && supports_popup {
|
||
self.open_subtool_slot = if self.open_subtool_slot == Some(slot_idx) {
|
||
None
|
||
} else {
|
||
Some(slot_idx)
|
||
};
|
||
}
|
||
if matches!(selected_tool, Tool::CustomShape(_)) {
|
||
self.dock
|
||
.reopen_pane(crate::dock::state::PaneType::CustomShapes);
|
||
}
|
||
}
|
||
Message::SubtoolsToggle(slot_idx) => {
|
||
let supports_popup = crate::sidebar::tool_slots()
|
||
.get(slot_idx)
|
||
.is_some_and(|slot| slot.tools.len() > 1);
|
||
if supports_popup {
|
||
let opening = self.open_subtool_slot != Some(slot_idx);
|
||
self.open_subtool_slot = opening.then_some(slot_idx);
|
||
self.active_tool_slot = slot_idx;
|
||
if opening && !self.sidebar_expanded {
|
||
self.sidebar_expanded = true;
|
||
self.settings.panel_layout.sidebar_expanded = true;
|
||
self.settings.panel_layout.sidebar_width = 72.0;
|
||
let _ = self.settings.save();
|
||
}
|
||
if opening {
|
||
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
|
||
if tool == Tool::Brush {
|
||
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
||
}
|
||
if matches!(tool, Tool::CustomShape(_)) {
|
||
self.dock
|
||
.reopen_pane(crate::dock::state::PaneType::CustomShapes);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Message::OverlayOutsideClick => {
|
||
if self.dock.active_auto_hide.take().is_some() {
|
||
return Task::none();
|
||
}
|
||
self.active_menu = None;
|
||
}
|
||
|
||
Message::FgColorChanged(color) => {
|
||
self.fg_color = color;
|
||
self.active_document_mut().engine.set_color(color);
|
||
if self.color_dragging {
|
||
self.pending_drag_color = Some(color);
|
||
} else {
|
||
self.add_recent_color(color);
|
||
self.colors_dirty = true;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
|
||
Message::BgColorChanged(color) => {
|
||
self.bg_color = color;
|
||
if !self.color_dragging {
|
||
self.add_recent_color(color);
|
||
self.colors_dirty = true;
|
||
}
|
||
if !self.color_dragging {
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
|
||
Message::ColorDragStarted(initial_color) => {
|
||
self.color_dragging = true;
|
||
self.pending_drag_color = Some(initial_color);
|
||
self.fg_color = initial_color;
|
||
self.active_document_mut().engine.set_color(initial_color);
|
||
}
|
||
|
||
Message::ColorDragEnded => {
|
||
self.color_dragging = false;
|
||
if let Some(final_color) = self.pending_drag_color.take() {
|
||
self.add_recent_color(final_color);
|
||
}
|
||
self.colors_dirty = false;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
|
||
Message::SwapColors => {
|
||
std::mem::swap(&mut self.fg_color, &mut self.bg_color);
|
||
let color = self.fg_color;
|
||
self.active_document_mut().engine.set_color(color);
|
||
self.add_recent_color(color);
|
||
self.colors_dirty = true;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
Message::ResetColors => {
|
||
// Reset fg=black, bg=white (egui: toolbox.rs:351-354).
|
||
self.fg_color = [0, 0, 0, 255];
|
||
self.bg_color = [255, 255, 255, 255];
|
||
let fg = self.fg_color;
|
||
self.active_document_mut().engine.set_color(fg);
|
||
self.colors_dirty = true;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
Message::ColorTabChanged(tab) => {
|
||
self.color_tab = tab;
|
||
}
|
||
Message::RefreshCustomShapes => {
|
||
// Refresh the active document's shape catalog and update the
|
||
// cached shape list used for drawing custom shapes on the canvas.
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.engine.shape_catalog.refresh();
|
||
self.cached_custom_shapes = doc
|
||
.engine
|
||
.shape_catalog
|
||
.all()
|
||
.iter()
|
||
.map(|e| (e.kind.clone(), e.svg.clone()))
|
||
.collect();
|
||
}
|
||
Message::OpenColorPicker(target, color) => {
|
||
self.color_picker_target = Some(target);
|
||
self.popup_color = color;
|
||
self.popup_color_hex = format!(
|
||
"{:02X}{:02X}{:02X}{:02X}",
|
||
color[0], color[1], color[2], color[3]
|
||
);
|
||
}
|
||
Message::CloseColorPicker => {
|
||
self.color_picker_target = None;
|
||
}
|
||
Message::ColorPickerTabChanged(tab) => {
|
||
self.popup_color_tab = tab.min(2);
|
||
}
|
||
Message::ColorPickerHexChanged(value) => {
|
||
self.popup_color_hex = value.clone();
|
||
if let Some(color) =
|
||
crate::color_picker::parse_hex_color(&value, self.popup_color[3])
|
||
{
|
||
return self.update(Message::PopupColorChanged(color));
|
||
}
|
||
}
|
||
Message::PopupColorChanged(color) => {
|
||
self.popup_color = color;
|
||
self.popup_color_hex = format!(
|
||
"{:02X}{:02X}{:02X}{:02X}",
|
||
color[0], color[1], color[2], color[3]
|
||
);
|
||
let routed = match self.color_picker_target {
|
||
Some(crate::color_picker::ColorPickerTarget::Foreground) => {
|
||
Some(Message::FgColorChanged(color))
|
||
}
|
||
Some(crate::color_picker::ColorPickerTarget::Background) => {
|
||
Some(Message::BgColorChanged(color))
|
||
}
|
||
Some(crate::color_picker::ColorPickerTarget::GeometryFill) => {
|
||
Some(Message::GeometryFillColorChanged(color))
|
||
}
|
||
Some(crate::color_picker::ColorPickerTarget::GeometryStroke) => {
|
||
Some(Message::GeometryStrokeColorChanged(color))
|
||
}
|
||
Some(crate::color_picker::ColorPickerTarget::StyleMain(index))
|
||
| Some(crate::color_picker::ColorPickerTarget::StyleHighlight(index)) => {
|
||
Some(Message::LayerStyleUpdateColor(index, color))
|
||
}
|
||
Some(crate::color_picker::ColorPickerTarget::StyleShadow(index)) => {
|
||
Some(Message::LayerStyleUpdateShadowColor(index, color))
|
||
}
|
||
None => None,
|
||
};
|
||
if let Some(message) = routed {
|
||
return self.update(message);
|
||
}
|
||
}
|
||
|
||
Message::CanvasPointerPressed { x, y, captured_at } => {
|
||
// Coordinates are already in canvas-space from the canvas widget
|
||
let canvas_x = x;
|
||
let canvas_y = y;
|
||
self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
|
||
let tool = self.tool_state.active_tool;
|
||
|
||
if let Some(transform) =
|
||
self.documents[self.active_doc].selection_transform.as_ref()
|
||
{
|
||
let handle = transform.hit_test(x, y, self.documents[self.active_doc].zoom);
|
||
if handle != TransformHandle::None {
|
||
self.documents[self.active_doc].transform_drag_handle = handle;
|
||
return self.update(Message::TransformDragStart(x, y));
|
||
}
|
||
}
|
||
|
||
let now = std::time::Instant::now();
|
||
let _is_double_click = if let Some((lx, ly)) = self.tool_state.last_click_pos {
|
||
let dx = canvas_x - lx;
|
||
let dy = canvas_y - ly;
|
||
let dist_sq = dx * dx + dy * dy;
|
||
now.duration_since(self.tool_state.last_click_time)
|
||
.as_millis()
|
||
< 300
|
||
&& dist_sq < 25.0
|
||
} else {
|
||
false
|
||
};
|
||
self.tool_state.last_click_time = now;
|
||
self.tool_state.last_click_pos = Some((canvas_x, canvas_y));
|
||
|
||
let paint_tool =
|
||
matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray);
|
||
if paint_tool && (self.modifiers.control() || self.modifiers.logo()) {
|
||
let width = self.documents[self.active_doc].engine.canvas_width();
|
||
let height = self.documents[self.active_doc].engine.canvas_height();
|
||
if canvas_x >= 0.0
|
||
&& canvas_y >= 0.0
|
||
&& (canvas_x as u32) < width
|
||
&& (canvas_y as u32) < height
|
||
{
|
||
let index = ((canvas_y as u32 * width + canvas_x as u32) * 4) as usize;
|
||
if index + 3 < self.documents[self.active_doc].composite_raw.len() {
|
||
let pixels = &self.documents[self.active_doc].composite_raw;
|
||
let color = [
|
||
pixels[index],
|
||
pixels[index + 1],
|
||
pixels[index + 2],
|
||
pixels[index + 3],
|
||
];
|
||
self.fg_color = color;
|
||
self.documents[self.active_doc].engine.set_color(color);
|
||
}
|
||
}
|
||
return Task::none();
|
||
}
|
||
|
||
if paint_tool && self.modifiers.shift() {
|
||
self.tool_state.is_resizing_brush = true;
|
||
self.tool_state.is_drawing = true;
|
||
self.tool_state.brush_resize_start_size = self.tool_state.brush_size;
|
||
self.tool_state.brush_resize_start_pos = Some((canvas_x, canvas_y));
|
||
return Task::none();
|
||
}
|
||
|
||
if let Some(req_type) = tool.allowed_layer_type() {
|
||
// Reject raster edits on non-editable layers
|
||
if req_type == hcie_engine_api::LayerType::Raster
|
||
&& !self.documents[self.active_doc]
|
||
.engine
|
||
.active_layer_is_editable()
|
||
{
|
||
// In the future: emit a status message here
|
||
return Task::none();
|
||
}
|
||
|
||
// Auto-create layer if current tool requires a different layer type
|
||
// NOTE: Vector layer creation is handled by add_vector_shape() in VectorDrawEnd
|
||
// to avoid creating duplicate layers. Only create Raster layers here.
|
||
if req_type != hcie_engine_api::LayerType::Text
|
||
&& req_type != hcie_engine_api::LayerType::Vector
|
||
{
|
||
if let Some(active) = self.documents[self.active_doc].engine.active_layer()
|
||
{
|
||
if active.layer_type != req_type {
|
||
let id = self.documents[self.active_doc].engine.add_layer("Layer");
|
||
self.documents[self.active_doc].engine.set_active_layer(id);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
match tool {
|
||
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
||
crate::canvas::perf::begin_stroke();
|
||
crate::canvas::perf::record_render_input(captured_at);
|
||
// Apply brush parameters before starting stroke
|
||
self.apply_brush_params();
|
||
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc].engine.set_tool(tool);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_eraser(tool == Tool::Eraser);
|
||
if tool != Tool::Pen {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.begin_stroke(layer_id, canvas_x, canvas_y);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.stroke_to(layer_id, canvas_x, canvas_y, 1.0);
|
||
}
|
||
self.tool_state.is_drawing = true;
|
||
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
|
||
self.tool_state.brush_accumulated_dist = 0.0;
|
||
self.refresh_canvas_if_needed();
|
||
}
|
||
Tool::Eyedropper => {
|
||
let cx = canvas_x as u32;
|
||
let cy = canvas_y as u32;
|
||
let w = self.documents[self.active_doc].engine.canvas_width();
|
||
let h = self.documents[self.active_doc].engine.canvas_height();
|
||
if cx < w && cy < h {
|
||
let pixels = &self.documents[self.active_doc].composite_raw;
|
||
let idx = ((cy * w + cx) * 4) as usize;
|
||
if idx + 3 < pixels.len() {
|
||
let color = [
|
||
pixels[idx],
|
||
pixels[idx + 1],
|
||
pixels[idx + 2],
|
||
pixels[idx + 3],
|
||
];
|
||
self.fg_color = color;
|
||
self.documents[self.active_doc].engine.set_color(color);
|
||
self.add_recent_color(color);
|
||
self.colors_dirty = true;
|
||
}
|
||
}
|
||
}
|
||
Tool::FloodFill => {
|
||
let cx = canvas_x as u32;
|
||
let cy = canvas_y as u32;
|
||
let color = self.fg_color;
|
||
let tol = self.settings.tool_settings.flood_fill_tolerance as u8;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.flood_fill(cx, cy, color, tol);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
Tool::Select => {
|
||
// Start selection rectangle drag
|
||
let sx = canvas_x;
|
||
let sy = canvas_y;
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::SelectionDragStart(sx, sy)
|
||
});
|
||
}
|
||
Tool::MagicWand => {
|
||
let cx = canvas_x as u32;
|
||
let cy = canvas_y as u32;
|
||
let tol = self.settings.tool_settings.magic_wand_tolerance as u8;
|
||
let previous = self.documents[self.active_doc]
|
||
.engine
|
||
.get_selection_mask()
|
||
.map(ToOwned::to_owned);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.create_selection_magic_wand(cx, cy, tol);
|
||
self.combine_current_selection(previous);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
Tool::SmartSelect => {
|
||
// No dedicated AI selection API exposed by the engine;
|
||
// fall back to a magic-wand pick at the click point so the
|
||
// tool remains usable without a configured SAM backend.
|
||
let cx = canvas_x as u32;
|
||
let cy = canvas_y as u32;
|
||
let tol = self.settings.tool_settings.magic_wand_tolerance as u8;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.create_selection_magic_wand(cx, cy, tol);
|
||
self.documents[self.active_doc].selection_model_dirty = true;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
Tool::Lasso => {
|
||
// Start a freehand lasso path.
|
||
self.documents[self.active_doc].lasso_points.clear();
|
||
let cx = canvas_x as u32;
|
||
let cy = canvas_y as u32;
|
||
self.documents[self.active_doc].lasso_points.push((cx, cy));
|
||
self.tool_state.is_drawing = true;
|
||
}
|
||
Tool::PolygonSelect => {
|
||
// Polygon lasso: accumulate vertices on each click. Close when
|
||
// clicking near the first vertex (≥3 points) or double-clicking.
|
||
let cx = canvas_x as u32;
|
||
let cy = canvas_y as u32;
|
||
let pts = &self.documents[self.active_doc].polygon_points;
|
||
let close_dist: i32 = 10;
|
||
let near_start = pts.len() >= 3
|
||
&& (pts[0].0 as i32 - cx as i32).abs() <= close_dist
|
||
&& (pts[0].1 as i32 - cy as i32).abs() <= close_dist;
|
||
let near_last = pts
|
||
.last()
|
||
.map(|l| {
|
||
(l.0 as i32 - cx as i32).abs() <= 4
|
||
&& (l.1 as i32 - cy as i32).abs() <= 4
|
||
})
|
||
.unwrap_or(false);
|
||
if near_start || near_last {
|
||
// Finalize polygon selection.
|
||
let pts =
|
||
std::mem::take(&mut self.documents[self.active_doc].polygon_points);
|
||
if pts.len() >= 3 {
|
||
let pts_ref: Vec<(u32, u32)> = pts;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.create_selection_polygon(&pts_ref);
|
||
self.documents[self.active_doc].selection_model_dirty = true;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
} else {
|
||
self.documents[self.active_doc]
|
||
.polygon_points
|
||
.push((cx, cy));
|
||
}
|
||
}
|
||
Tool::VisionSelect => {
|
||
// Start a bounding-box drag for vision segmentation.
|
||
self.tool_state.is_drawing = true;
|
||
self.documents[self.active_doc].vision_rect =
|
||
Some((canvas_x, canvas_y, canvas_x, canvas_y));
|
||
}
|
||
Tool::Move => {
|
||
// Begin a floating-pixel transform from the active
|
||
// selection mask, clearing the source area first.
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let mask = doc.engine.get_selection_mask().map(|m| m.to_vec());
|
||
let w = doc.engine.canvas_width();
|
||
let h = doc.engine.canvas_height();
|
||
if let Some(mask_data) = mask {
|
||
let cx = canvas_x as u32;
|
||
let cy = canvas_y as u32;
|
||
let in_selection =
|
||
(cx < w && cy < h) && mask_data[(cy * w + cx) as usize] > 0;
|
||
if in_selection {
|
||
let tr = selection::SelectionTransform::from_engine(
|
||
&mut doc.engine,
|
||
&mask_data,
|
||
w,
|
||
h,
|
||
);
|
||
if !tr.is_empty() {
|
||
let Some(original_layer) = doc.engine.get_active_layer_pixels()
|
||
else {
|
||
return Task::none();
|
||
};
|
||
let mut cut_layer = original_layer.clone();
|
||
clear_masked_pixels_without_history(&mut cut_layer, &mask_data);
|
||
log::debug!(
|
||
"[TransformMove] begin atomic transaction bounds={:?}, selected_pixels={}",
|
||
preview_bounds(&tr, w, h),
|
||
mask_data.iter().filter(|coverage| **coverage > 0).count()
|
||
);
|
||
doc.engine.set_active_layer_pixels(cut_layer);
|
||
doc.engine.selection_clear();
|
||
set_document_selection_mask(doc, Some(mask_data.clone()));
|
||
doc.transform_source = Some(tr.clone());
|
||
doc.transform_layer_baseline = Some(original_layer);
|
||
doc.transform_selection_baseline = Some(mask_data);
|
||
doc.selection_transform = Some(tr);
|
||
doc.transform_drag_handle = TransformHandle::Move;
|
||
doc.transform_drag_start = Some((canvas_x, canvas_y));
|
||
// Force a composite refresh so the cleared source
|
||
// area is visible immediately.
|
||
doc.engine.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.transform_preview_base = Some(doc.composite_raw.clone());
|
||
render_transform_preview(doc, None);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Tool::Gradient => {
|
||
// Start a gradient endpoint drag.
|
||
let sx = canvas_x;
|
||
let sy = canvas_y;
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::GradientDragStart(sx, sy)
|
||
});
|
||
}
|
||
Tool::Text => {
|
||
// Begin (or continue) an on-canvas text draft at the
|
||
// click point. A second click elsewhere moves the draft.
|
||
let cx = canvas_x;
|
||
let cy = canvas_y;
|
||
let doc = &mut self.documents[self.active_doc];
|
||
if doc.text_draft.is_none() {
|
||
let mut draft = TextDraft::default();
|
||
draft.color = self.fg_color;
|
||
draft.x = cx;
|
||
draft.y = cy;
|
||
doc.text_draft = Some(draft);
|
||
} else {
|
||
doc.text_draft.as_mut().unwrap().x = cx;
|
||
doc.text_draft.as_mut().unwrap().y = cy;
|
||
}
|
||
}
|
||
Tool::VectorRect
|
||
| Tool::VectorCircle
|
||
| Tool::VectorLine
|
||
| Tool::VectorArrow
|
||
| Tool::VectorStar
|
||
| Tool::VectorPolygon
|
||
| Tool::VectorRhombus
|
||
| Tool::VectorCylinder
|
||
| Tool::VectorHeart
|
||
| Tool::VectorBubble
|
||
| Tool::VectorGear
|
||
| Tool::VectorCross
|
||
| Tool::VectorCrescent
|
||
| Tool::VectorBolt
|
||
| Tool::VectorArrow4
|
||
| Tool::CustomShape(_) => {
|
||
// Start vector shape drawing (built-in + custom SVG shapes share one drag).
|
||
let sx = canvas_x;
|
||
let sy = canvas_y;
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::VectorDrawStart(sx, sy)
|
||
});
|
||
}
|
||
Tool::Crop => {
|
||
// Start crop drag
|
||
let sx = canvas_x;
|
||
let sy = canvas_y;
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::CropDragStart(sx, sy)
|
||
});
|
||
}
|
||
Tool::VectorSelect => {
|
||
// Click to select vector shapes under cursor.
|
||
let cx = canvas_x;
|
||
let cy = canvas_y;
|
||
return Task::perform(async move {}, move |_| Message::VectorSelectClick {
|
||
x: cx,
|
||
y: cy,
|
||
});
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
Message::CanvasPointerMoved { x, y, captured_at } => {
|
||
// Coordinates are already in canvas-space from the canvas widget
|
||
self.canvas_cursor_pos = Some((x as u32, y as u32));
|
||
|
||
if self.documents[self.active_doc]
|
||
.transform_drag_start
|
||
.is_some()
|
||
{
|
||
return self.update(Message::TransformDragMove(x, y));
|
||
}
|
||
|
||
if self.tool_state.is_drawing {
|
||
// Selection tools (Lasso, Polygon, Vision, Rect) use is_drawing
|
||
// to track drag state but must not enter the brush code path.
|
||
let is_selection_tool = matches!(
|
||
self.tool_state.active_tool,
|
||
Tool::Lasso
|
||
| Tool::PolygonSelect
|
||
| Tool::Select
|
||
| Tool::VisionSelect
|
||
| Tool::MagicWand
|
||
| Tool::SmartSelect
|
||
);
|
||
if is_selection_tool {
|
||
// Fall through to the dedicated selection drag handlers below.
|
||
} else {
|
||
let is_ctrl = self.modifiers.control() || self.modifiers.logo();
|
||
if is_ctrl {
|
||
// Pick color
|
||
let w = self.documents[self.active_doc].engine.canvas_width();
|
||
let h = self.documents[self.active_doc].engine.canvas_height();
|
||
if x >= 0.0 && y >= 0.0 && (x as u32) < w && (y as u32) < h {
|
||
let pixels = &self.documents[self.active_doc].composite_raw;
|
||
let idx = ((y as u32 * w + x as u32) * 4) as usize;
|
||
if idx + 3 < pixels.len() {
|
||
let color = [
|
||
pixels[idx],
|
||
pixels[idx + 1],
|
||
pixels[idx + 2],
|
||
pixels[idx + 3],
|
||
];
|
||
self.fg_color = color;
|
||
self.documents[self.active_doc].engine.set_color(color);
|
||
self.add_recent_color(color);
|
||
self.colors_dirty = true;
|
||
}
|
||
}
|
||
} else if self.tool_state.is_resizing_brush {
|
||
if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos {
|
||
let dx = x - start_x;
|
||
let new_size = (self.tool_state.brush_resize_start_size + dx)
|
||
.clamp(1.0, 500.0);
|
||
self.tool_state.brush_size = new_size;
|
||
match self.tool_state.active_tool {
|
||
Tool::Pen => self.settings.tool_settings.pen_size = new_size,
|
||
Tool::Brush => {
|
||
self.settings.tool_settings.brush_size = new_size
|
||
}
|
||
Tool::Eraser => {
|
||
self.settings.tool_settings.eraser_size = new_size
|
||
}
|
||
Tool::Spray => {
|
||
self.settings.tool_settings.spray_radius = new_size
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
} else {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
|
||
if self.tool_state.active_tool == Tool::Pen {
|
||
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
|
||
if !self.documents[self.active_doc].engine.is_stroke_active() {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.begin_stroke(layer_id, last_x, last_y);
|
||
}
|
||
let pressure = self
|
||
.tablet_state
|
||
.lock()
|
||
.map(|state| state.current_pressure())
|
||
.unwrap_or(1.0);
|
||
crate::canvas::perf::record_render_input(captured_at);
|
||
let engine_start = std::time::Instant::now();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.draw_pen_segment(layer_id, last_x, last_y, x, y, pressure);
|
||
crate::canvas::perf::record_duration(
|
||
"engine_draw",
|
||
engine_start.elapsed(),
|
||
);
|
||
self.tool_state.last_stroke_pos = Some((x, y));
|
||
}
|
||
return Task::none();
|
||
}
|
||
|
||
let spacing: f32 = match self.tool_state.active_tool {
|
||
Tool::Spray => (self.tool_state.brush_size * 0.25).max(2.0),
|
||
Tool::Brush | Tool::Eraser => {
|
||
(self.tool_state.brush_size * 0.12).max(1.0)
|
||
}
|
||
_ => 1.0_f32,
|
||
};
|
||
|
||
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
|
||
let dx = x - last_x;
|
||
let dy = y - last_y;
|
||
let dist = (dx * dx + dy * dy).sqrt();
|
||
|
||
// Use constant pressure for mouse input
|
||
// Real tablet pressure comes from tablet_state
|
||
let pressure = self
|
||
.tablet_state
|
||
.lock()
|
||
.map(|s| s.current_pressure())
|
||
.unwrap_or(1.0);
|
||
|
||
if dist >= spacing {
|
||
crate::canvas::perf::record_render_input(captured_at);
|
||
let engine_start = std::time::Instant::now();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.stroke_to(layer_id, x, y, pressure);
|
||
crate::canvas::perf::record_duration(
|
||
"engine_draw",
|
||
engine_start.elapsed(),
|
||
);
|
||
self.tool_state.last_stroke_pos = Some((x, y));
|
||
}
|
||
} else {
|
||
crate::canvas::perf::record_render_input(captured_at);
|
||
let engine_start = std::time::Instant::now();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.stroke_to(layer_id, x, y, 1.0);
|
||
crate::canvas::perf::record_duration(
|
||
"engine_draw",
|
||
engine_start.elapsed(),
|
||
);
|
||
self.tool_state.last_stroke_pos = Some((x, y));
|
||
self.refresh_canvas_if_needed();
|
||
}
|
||
}
|
||
} // end else (non-selection tool)
|
||
}
|
||
|
||
// Handle selection drag
|
||
if self.tool_state.active_tool == Tool::Select {
|
||
let mx = x;
|
||
let my = y;
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::SelectionDragMove(mx, my)
|
||
});
|
||
}
|
||
|
||
// Handle lasso freehand drag (accumulate points).
|
||
if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing {
|
||
let mx = x;
|
||
let my = y;
|
||
return Task::perform(async move {}, move |_| Message::LassoDragMove(mx, my));
|
||
}
|
||
|
||
// Handle vision-select bounding-box drag.
|
||
if self.tool_state.active_tool == Tool::VisionSelect && self.tool_state.is_drawing {
|
||
let mx = x;
|
||
let my = y;
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::GradientDragMove(mx, my)
|
||
});
|
||
}
|
||
|
||
// Handle gradient endpoint drag.
|
||
if self.tool_state.active_tool == Tool::Gradient && self.tool_state.is_drawing {
|
||
let mx = x;
|
||
let my = y;
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::GradientDragMove(mx, my)
|
||
});
|
||
}
|
||
|
||
// Handle vector draw drag (all shape tools share one drag).
|
||
if is_vector_shape_tool(self.tool_state.active_tool) {
|
||
let mx = x;
|
||
let my = y;
|
||
return Task::perform(async move {}, move |_| Message::VectorDrawMove(mx, my));
|
||
}
|
||
|
||
// Handle crop drag
|
||
if self.tool_state.active_tool == Tool::Crop {
|
||
let mx = x;
|
||
let my = y;
|
||
return Task::perform(async move {}, move |_| Message::CropDragMove(mx, my));
|
||
}
|
||
|
||
// Handle vector select drag
|
||
if self.tool_state.active_tool == Tool::VectorSelect
|
||
&& self.vector_drag_last.is_some()
|
||
{
|
||
let mx = x;
|
||
let my = y;
|
||
return Task::perform(async move {}, move |_| Message::VectorSelectDragMove {
|
||
x: mx,
|
||
y: my,
|
||
});
|
||
}
|
||
}
|
||
|
||
Message::CanvasPointerReleased => {
|
||
if self.tool_state.is_resizing_brush {
|
||
self.tool_state.is_resizing_brush = false;
|
||
self.tool_state.is_drawing = false;
|
||
self.tool_state.brush_resize_start_pos = None;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
return Task::none();
|
||
}
|
||
|
||
if self.documents[self.active_doc]
|
||
.transform_drag_start
|
||
.is_some()
|
||
{
|
||
return self.update(Message::TransformDragEnd);
|
||
}
|
||
|
||
// Lasso release finalizes a freehand selection path.
|
||
if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing {
|
||
self.tool_state.is_drawing = false;
|
||
return Task::perform(async {}, |_| Message::LassoDragEnd);
|
||
}
|
||
|
||
// VisionSelect release finalizes the bbox as a rectangular selection.
|
||
if self.tool_state.active_tool == Tool::VisionSelect && self.tool_state.is_drawing {
|
||
self.tool_state.is_drawing = false;
|
||
let rect = self.documents[self.active_doc].vision_rect;
|
||
if let Some((x0, y0, x1, y1)) = rect {
|
||
let sx = x0.min(x1) as u32;
|
||
let sy = y0.min(y1) as u32;
|
||
let ex = x0.max(x1) as u32;
|
||
let ey = y0.max(y1) as u32;
|
||
if ex > sx && ey > sy {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.create_selection_rect(sx, sy, ex, ey);
|
||
self.combine_current_selection(None);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
self.documents[self.active_doc].vision_rect = None;
|
||
}
|
||
|
||
// Gradient release applies a linear/radial gradient fill.
|
||
if self.tool_state.active_tool == Tool::Gradient && self.tool_state.is_drawing {
|
||
self.tool_state.is_drawing = false;
|
||
return Task::perform(async {}, |_| Message::GradientDragEnd);
|
||
}
|
||
|
||
if self.tool_state.is_drawing {
|
||
// Selection tools use is_drawing to track drag state but never
|
||
// start a brush stroke — skip end_stroke / commit_pending_history
|
||
// so they don't push a bogus undo entry that empties the canvas.
|
||
let is_selection_tool = matches!(
|
||
self.tool_state.active_tool,
|
||
Tool::Lasso
|
||
| Tool::PolygonSelect
|
||
| Tool::Select
|
||
| Tool::VisionSelect
|
||
| Tool::MagicWand
|
||
| Tool::SmartSelect
|
||
);
|
||
if !is_selection_tool {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc].engine.end_stroke(layer_id);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.commit_pending_history();
|
||
|
||
self.tool_state.is_drawing = false;
|
||
self.tool_state.last_stroke_pos = None;
|
||
self.documents[self.active_doc].modified = true;
|
||
|
||
self.refresh_composite_if_needed();
|
||
crate::canvas::perf::request_finish_stroke();
|
||
|
||
// If the background thread hasn't finished yet, schedule
|
||
// a delayed poll so the history entry is committed.
|
||
if self.documents[self.active_doc].engine.has_pending_history() {
|
||
return Task::perform(
|
||
async {
|
||
tokio::time::sleep(std::time::Duration::from_millis(8)).await;
|
||
},
|
||
|_| Message::CompositeRefreshPending,
|
||
);
|
||
}
|
||
} // end if !is_selection_tool
|
||
}
|
||
|
||
// End selection drag
|
||
if self.tool_state.active_tool == Tool::Select {
|
||
return Task::perform(async {}, |_| Message::SelectionDragEnd);
|
||
}
|
||
|
||
// End vector draw (all shape tools)
|
||
if is_vector_shape_tool(self.tool_state.active_tool) {
|
||
return Task::perform(async {}, |_| Message::VectorDrawEnd);
|
||
}
|
||
|
||
// End crop drag
|
||
if self.tool_state.active_tool == Tool::Crop {
|
||
return Task::perform(async {}, |_| Message::CropDragEnd);
|
||
}
|
||
|
||
// End vector select drag
|
||
if self.tool_state.active_tool == Tool::VectorSelect
|
||
&& self.vector_drag_last.is_some()
|
||
{
|
||
return Task::perform(async {}, |_| Message::VectorSelectDragEnd);
|
||
}
|
||
}
|
||
|
||
Message::CanvasPointerRightClicked {
|
||
x: _,
|
||
y: _,
|
||
screen_x,
|
||
screen_y,
|
||
} => {
|
||
// Ignore canvas_x, canvas_y for now, just pop the menu
|
||
self.canvas_context_menu = Some((screen_x, screen_y));
|
||
}
|
||
|
||
Message::CanvasContextMenuClose => {
|
||
self.canvas_context_menu = None;
|
||
}
|
||
|
||
Message::CanvasPanZoom { zoom, pan_offset } => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.zoom = zoom;
|
||
doc.pan_offset = pan_offset;
|
||
}
|
||
|
||
Message::CanvasCursorPos { x, y } => {
|
||
crate::canvas::perf::record_idle_pointer_message(false);
|
||
self.canvas_cursor_pos = Some((x, y));
|
||
}
|
||
|
||
// No-op — this message exists solely to trigger a view() rebuild
|
||
// so the crosshair overlay redraws at the latest cursor position.
|
||
// See Strategy 1 in the cursor lag fix.
|
||
Message::CursorOverlayRedraw => {}
|
||
|
||
Message::CanvasZoomSet(z) => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
|
||
// Keep pan_offset stable so the visible content does not jump
|
||
// when zooming via keyboard/menu/slider. The canvas view
|
||
// centers on pane+pan_offset, so holding pan_offset keeps the
|
||
// same logical content anchored.
|
||
}
|
||
|
||
Message::CanvasZoomRelative(factor) => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
||
// Keep pan_offset — zooming relative to center via keyboard.
|
||
}
|
||
Message::FitToArea => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let fit_x = (doc.pane_size.0 - 24.0) / doc.engine.canvas_width() as f32;
|
||
let fit_y = (doc.pane_size.1 - 24.0) / doc.engine.canvas_height() as f32;
|
||
doc.zoom = fit_x.min(fit_y).clamp(ZOOM_MIN, ZOOM_MAX);
|
||
doc.pan_offset = Vector::ZERO;
|
||
}
|
||
|
||
Message::CanvasSize((w, h)) => {
|
||
self.documents[self.active_doc].pane_size = (w, h);
|
||
// Feed the canvas pane size to the tablet state so evdev
|
||
// absolute-axis position mapping can resolve to canvas coords.
|
||
if let Ok(mut ts) = self.tablet_state.lock() {
|
||
ts.set_screen_size(w.max(1.0), h.max(1.0));
|
||
}
|
||
// Keep pan_offset so the canvas stays anchored when the pane
|
||
// is resized (e.g. dock splitter dragged).
|
||
}
|
||
|
||
Message::CanvasFrameTick | Message::CompositeRefresh => {
|
||
self.refresh_canvas_if_needed();
|
||
}
|
||
|
||
Message::Undo | Message::Redo => {
|
||
match message {
|
||
Message::Undo => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let current_marker = doc.engine.history_current();
|
||
if current_marker == doc.selection_history_marker
|
||
&& !doc.selection_history.is_empty()
|
||
{
|
||
// Undo selection
|
||
if let Some(prev_mask) = doc.selection_history.pop() {
|
||
if let Some(mask) = prev_mask {
|
||
doc.engine.set_selection_mask(mask);
|
||
} else {
|
||
doc.engine.selection_clear();
|
||
}
|
||
doc.selection_model_dirty = true;
|
||
}
|
||
} else {
|
||
doc.engine.undo();
|
||
doc.selection_history.clear();
|
||
}
|
||
}
|
||
Message::Redo => {
|
||
self.documents[self.active_doc].engine.redo();
|
||
self.documents[self.active_doc].selection_history.clear();
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
|
||
// Poll pending background history commits. The brush engine's
|
||
// end_stroke runs a background thread that may not finish before
|
||
// the first commit_pending_history() call. This message retries
|
||
// at 16 ms intervals until the queue drains.
|
||
Message::CompositeRefreshPending => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let committed = doc.engine.commit_pending_history();
|
||
if committed && doc.engine.has_pending_history() {
|
||
// Background thread still producing — schedule another poll.
|
||
return Task::perform(
|
||
async {
|
||
tokio::time::sleep(std::time::Duration::from_millis(16)).await;
|
||
},
|
||
|_| Message::CompositeRefreshPending,
|
||
);
|
||
}
|
||
// Canvas pixels were already staged on release; only panel metadata changed.
|
||
self.refresh_panel_caches();
|
||
}
|
||
|
||
// ── Layer operations ──────────────────────────
|
||
Message::LayerSelect(id) => {
|
||
if self.preview_baseline.is_some() {
|
||
self.restore_preview();
|
||
self.filter_preview_active = false;
|
||
self.selected_filter = None;
|
||
self.filter_params = serde_json::json!({});
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
let doc = &mut self.documents[self.active_doc];
|
||
if doc.engine.active_layer_id() != id {
|
||
doc.selected_vector_shape = None;
|
||
doc.vector_cycle_index = 0;
|
||
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
|
||
self.vector_shapes_snapshot = None;
|
||
self.vector_drag_last = None;
|
||
self.vector_drag_session = None;
|
||
doc.vector_drag_preview = None;
|
||
self.bool_shape_a = None;
|
||
self.bool_shape_b = None;
|
||
doc.engine.set_active_layer(id);
|
||
}
|
||
}
|
||
|
||
Message::LayerAdd => {
|
||
let count = self.documents[self.active_doc].engine.get_layer_count();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.add_layer(&format!("Layer {}", count + 1));
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerAddMask(id) => {
|
||
self.documents[self.active_doc].engine.add_layer_mask(id);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.documents[self.active_doc].cached_layers =
|
||
self.documents[self.active_doc].engine.layer_infos();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerRemoveMask(id) => {
|
||
self.documents[self.active_doc].engine.remove_layer_mask(id);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.documents[self.active_doc].cached_layers =
|
||
self.documents[self.active_doc].engine.layer_infos();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerToggleMask(id) => {
|
||
self.documents[self.active_doc].engine.toggle_layer_mask(id);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.documents[self.active_doc].cached_layers =
|
||
self.documents[self.active_doc].engine.layer_infos();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerSetEditMask(editing) => {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_editing_mask(editing);
|
||
self.documents[self.active_doc].cached_layers =
|
||
self.documents[self.active_doc].engine.layer_infos();
|
||
}
|
||
|
||
Message::LayerToggleEditMask => {
|
||
self.documents[self.active_doc].engine.toggle_editing_mask();
|
||
self.documents[self.active_doc].cached_layers =
|
||
self.documents[self.active_doc].engine.layer_infos();
|
||
}
|
||
|
||
Message::LayerDelete(id) => {
|
||
self.documents[self.active_doc].engine.delete_layer(id);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerToggleVisibility(id) => {
|
||
let visible = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_info(id)
|
||
.map(|l| !l.visible)
|
||
.unwrap_or(true);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_layer_visible(id, visible);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerToggleLock(id) => {
|
||
let locked = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_info(id)
|
||
.map(|l| !l.locked)
|
||
.unwrap_or(false);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_layer_locked(id, locked);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.documents[self.active_doc].cached_layers =
|
||
self.documents[self.active_doc].engine.layer_infos();
|
||
}
|
||
|
||
Message::LayerSetOpacity(id, opacity) => {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_layer_opacity(id, opacity);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerMergeDown => {
|
||
self.documents[self.active_doc].engine.merge_down();
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerFlatten => {
|
||
self.documents[self.active_doc].engine.flatten_image();
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerMoveUp(id) => {
|
||
// The layer list is shown front-to-back: the top/front layer is first.
|
||
// "Move up" therefore means toward the front of the canvas, which is
|
||
// a smaller array index in engine layer order (back is index 0).
|
||
let infos = self.documents[self.active_doc].engine.layer_infos();
|
||
if let Some(pos) = infos.iter().position(|l| l.id == id) {
|
||
if pos > 0 {
|
||
let target_idx = pos - 1;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.move_layer(id, target_idx);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
}
|
||
|
||
Message::LayerMoveDown(id) => {
|
||
// "Move down" means toward the back of the canvas, which is a larger
|
||
// array index in engine layer order.
|
||
let infos = self.documents[self.active_doc].engine.layer_infos();
|
||
if let Some(pos) = infos.iter().position(|l| l.id == id) {
|
||
if pos + 1 < infos.len() {
|
||
let target_idx = pos + 1;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.move_layer(id, target_idx);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
}
|
||
|
||
Message::LayerSetBlendMode(id, mode) => {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_layer_blend_mode(id, mode);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerSetFillOpacity(_id, _opacity) => {
|
||
// Fill opacity is not yet exposed via engine API; placeholder for future use.
|
||
}
|
||
|
||
Message::LayerAddGroup => {
|
||
let count = self.documents[self.active_doc].engine.get_layer_count();
|
||
let active_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let active_is_group = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_info(active_id)
|
||
.is_some_and(|layer| layer.layer_type == hcie_engine_api::LayerType::Group);
|
||
let group_id = self.documents[self.active_doc]
|
||
.engine
|
||
.add_group(&format!("Group {}", count + 1));
|
||
if active_id != 0 && active_id != group_id && !active_is_group {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_layer_parent(active_id, Some(group_id));
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_active_layer(group_id);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
Message::LayerToggleCollapse(id) => {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.toggle_group_collapsed(id);
|
||
self.documents[self.active_doc].modified = true;
|
||
}
|
||
|
||
// ── History ───────────────────────────────────
|
||
Message::HistoryJumpTo(idx) => {
|
||
self.documents[self.active_doc].engine.jump_to_history(idx);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
// ── Text tool ─────────────────────────────────
|
||
// Text tool handlers (TextSizeChanged, TextColorChanged, TextAlignChanged,
|
||
// TextAccept, TextCancel, plus full editor handlers) are defined in the
|
||
// brush/tool-options section below.
|
||
|
||
// ── Layer styles ──────────────────────────────
|
||
Message::LayerStyleToggle(index) => {
|
||
if let Some(baseline) = self.layer_style_baseline.as_mut() {
|
||
baseline.changed = true;
|
||
}
|
||
// Toggle layer style enabled state.
|
||
// BUG FIX: When style doesn't exist, first click creates it ENABLED (not disabled).
|
||
// Previous logic used map_or(false, ...) which created disabled styles on first click.
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
if (index as usize) < disc_names.len() {
|
||
let disc = disc_names[index as usize];
|
||
// Find existing style
|
||
let existing = styles.iter().find(|s| {
|
||
matches!(
|
||
(disc, s),
|
||
("DropShadow", LayerStyle::DropShadow { .. })
|
||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||
| ("Satin", LayerStyle::Satin { .. })
|
||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||
| ("Stroke", LayerStyle::Stroke { .. })
|
||
)
|
||
});
|
||
|
||
// Determine new style:
|
||
// - Style doesn't exist → create default style with enabled: true
|
||
// - Style exists → clone and toggle enabled flag
|
||
let new_style = match existing {
|
||
None => match disc {
|
||
"DropShadow" => LayerStyle::DropShadow {
|
||
enabled: true,
|
||
opacity: 0.75,
|
||
angle: 120.0,
|
||
distance: 5.0,
|
||
spread: 0.0,
|
||
size: 5.0,
|
||
color: [0, 0, 0, 255],
|
||
blend_mode: "Multiply".to_string(),
|
||
},
|
||
"InnerShadow" => LayerStyle::InnerShadow {
|
||
enabled: true,
|
||
opacity: 0.75,
|
||
angle: 120.0,
|
||
distance: 5.0,
|
||
spread: 0.0,
|
||
size: 5.0,
|
||
color: [0, 0, 0, 255],
|
||
blend_mode: "Multiply".to_string(),
|
||
},
|
||
"OuterGlow" => LayerStyle::OuterGlow {
|
||
enabled: true,
|
||
opacity: 0.75,
|
||
spread: 0.0,
|
||
size: 5.0,
|
||
color: [255, 255, 190, 255],
|
||
blend_mode: "Screen".to_string(),
|
||
},
|
||
"InnerGlow" => LayerStyle::InnerGlow {
|
||
enabled: true,
|
||
opacity: 0.75,
|
||
spread: 0.0,
|
||
size: 5.0,
|
||
color: [255, 255, 190, 255],
|
||
blend_mode: "Screen".to_string(),
|
||
},
|
||
"BevelEmboss" => LayerStyle::BevelEmboss {
|
||
enabled: true,
|
||
depth: 1.0,
|
||
size: 5.0,
|
||
angle: 120.0,
|
||
altitude: 30.0,
|
||
highlight_opacity: 0.75,
|
||
shadow_opacity: 0.75,
|
||
direction: "Up".to_string(),
|
||
style: "InnerBevel".to_string(),
|
||
technique: "Smooth".to_string(),
|
||
soften: 0.0,
|
||
highlight_blend_mode: "Screen".to_string(),
|
||
highlight_color: [255, 255, 255, 255],
|
||
shadow_blend_mode: "Multiply".to_string(),
|
||
shadow_color: [0, 0, 0, 255],
|
||
contour: "Linear".to_string(),
|
||
},
|
||
"Satin" => LayerStyle::Satin {
|
||
enabled: true,
|
||
opacity: 0.5,
|
||
angle: 120.0,
|
||
distance: 11.0,
|
||
size: 14.0,
|
||
color: [0, 0, 0, 255],
|
||
invert: true,
|
||
},
|
||
"ColorOverlay" => LayerStyle::ColorOverlay {
|
||
enabled: true,
|
||
opacity: 1.0,
|
||
color: [255, 0, 0, 255],
|
||
blend_mode: "Normal".to_string(),
|
||
},
|
||
"GradientOverlay" => LayerStyle::GradientOverlay {
|
||
enabled: true,
|
||
opacity: 1.0,
|
||
blend_mode: "Normal".to_string(),
|
||
angle: 90.0,
|
||
scale: 1.0,
|
||
gradient_type: 0,
|
||
},
|
||
"PatternOverlay" => LayerStyle::PatternOverlay {
|
||
enabled: true,
|
||
opacity: 1.0,
|
||
blend_mode: "Normal".to_string(),
|
||
scale: 1.0,
|
||
pattern_name: "".to_string(),
|
||
},
|
||
"Stroke" => LayerStyle::Stroke {
|
||
enabled: true,
|
||
size: 3.0,
|
||
position: "Outside".to_string(),
|
||
opacity: 1.0,
|
||
color: [255, 0, 0, 255],
|
||
blend_mode: "Normal".to_string(),
|
||
},
|
||
_ => unreachable!(),
|
||
},
|
||
Some(s) => {
|
||
let mut cloned = s.clone();
|
||
match &mut cloned {
|
||
LayerStyle::DropShadow { enabled, .. }
|
||
| LayerStyle::InnerShadow { enabled, .. }
|
||
| LayerStyle::OuterGlow { enabled, .. }
|
||
| LayerStyle::InnerGlow { enabled, .. }
|
||
| LayerStyle::BevelEmboss { enabled, .. }
|
||
| LayerStyle::Satin { enabled, .. }
|
||
| LayerStyle::ColorOverlay { enabled, .. }
|
||
| LayerStyle::GradientOverlay { enabled, .. }
|
||
| LayerStyle::PatternOverlay { enabled, .. }
|
||
| LayerStyle::Stroke { enabled, .. } => {
|
||
*enabled = !*enabled;
|
||
}
|
||
}
|
||
cloned
|
||
}
|
||
};
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.update_layer_style(layer_id, new_style);
|
||
}
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::LayerStyleSelect(index) => {
|
||
self.selected_style = index;
|
||
}
|
||
Message::LayerStyleApply => {
|
||
self.documents[self.active_doc].modified = true;
|
||
self.layer_style_baseline = None;
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::LayerStyleCancel => {
|
||
if let Some(baseline) = self.layer_style_baseline.take() {
|
||
if baseline.changed && baseline.document_index < self.documents.len() {
|
||
let doc = &mut self.documents[baseline.document_index];
|
||
for index in 0..10 {
|
||
doc.engine
|
||
.remove_layer_style_by_index(baseline.layer_id, index);
|
||
}
|
||
for style in baseline.styles {
|
||
doc.engine.update_layer_style(baseline.layer_id, style);
|
||
}
|
||
doc.modified = baseline.was_modified;
|
||
self.active_doc = baseline.document_index;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
self.active_dialog = ActiveDialog::None;
|
||
}
|
||
Message::LayerStyleAdd => {
|
||
log::warn!("LayerStyleAdd is unavailable; select one of the ten explicit styles");
|
||
}
|
||
Message::LayerStyleUpdateParam(index, param, value) => {
|
||
if let Some(baseline) = self.layer_style_baseline.as_mut() {
|
||
baseline.changed = true;
|
||
}
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
if index < disc_names.len() {
|
||
let disc = disc_names[index];
|
||
let existing = styles.iter().find(|s| {
|
||
matches!(
|
||
(disc, s),
|
||
("DropShadow", LayerStyle::DropShadow { .. })
|
||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||
| ("Satin", LayerStyle::Satin { .. })
|
||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||
| ("Stroke", LayerStyle::Stroke { .. })
|
||
)
|
||
});
|
||
|
||
if let Some(style) = existing {
|
||
let mut new_style = style.clone();
|
||
// Update the specific parameter
|
||
match (&mut new_style, param.as_str()) {
|
||
(LayerStyle::DropShadow { opacity, .. }, "opacity") => {
|
||
*opacity = value
|
||
}
|
||
(LayerStyle::DropShadow { angle, .. }, "angle") => *angle = value,
|
||
(LayerStyle::DropShadow { distance, .. }, "distance") => {
|
||
*distance = value
|
||
}
|
||
(LayerStyle::DropShadow { spread, .. }, "spread") => {
|
||
*spread = value
|
||
}
|
||
(LayerStyle::DropShadow { size, .. }, "size") => *size = value,
|
||
(LayerStyle::InnerShadow { opacity, .. }, "opacity") => {
|
||
*opacity = value
|
||
}
|
||
(LayerStyle::InnerShadow { angle, .. }, "angle") => *angle = value,
|
||
(LayerStyle::InnerShadow { distance, .. }, "distance") => {
|
||
*distance = value
|
||
}
|
||
(LayerStyle::InnerShadow { spread, .. }, "spread") => {
|
||
*spread = value
|
||
}
|
||
(LayerStyle::InnerShadow { size, .. }, "size") => *size = value,
|
||
(LayerStyle::OuterGlow { opacity, .. }, "opacity") => {
|
||
*opacity = value
|
||
}
|
||
(LayerStyle::OuterGlow { spread, .. }, "spread") => *spread = value,
|
||
(LayerStyle::OuterGlow { size, .. }, "size") => *size = value,
|
||
(LayerStyle::InnerGlow { opacity, .. }, "opacity") => {
|
||
*opacity = value
|
||
}
|
||
(LayerStyle::InnerGlow { spread, .. }, "spread") => *spread = value,
|
||
(LayerStyle::InnerGlow { size, .. }, "size") => *size = value,
|
||
(LayerStyle::BevelEmboss { depth, .. }, "depth") => *depth = value,
|
||
(LayerStyle::BevelEmboss { size, .. }, "size") => *size = value,
|
||
(LayerStyle::BevelEmboss { angle, .. }, "angle") => *angle = value,
|
||
(LayerStyle::BevelEmboss { altitude, .. }, "altitude") => {
|
||
*altitude = value
|
||
}
|
||
(LayerStyle::BevelEmboss { soften, .. }, "soften") => {
|
||
*soften = value
|
||
}
|
||
(
|
||
LayerStyle::BevelEmboss {
|
||
highlight_opacity,
|
||
shadow_opacity,
|
||
..
|
||
},
|
||
"opacity",
|
||
) => {
|
||
*highlight_opacity = value;
|
||
*shadow_opacity = value;
|
||
}
|
||
(
|
||
LayerStyle::BevelEmboss {
|
||
highlight_opacity, ..
|
||
},
|
||
"highlight_opacity",
|
||
) => {
|
||
*highlight_opacity = value;
|
||
}
|
||
(
|
||
LayerStyle::BevelEmboss { shadow_opacity, .. },
|
||
"shadow_opacity",
|
||
) => {
|
||
*shadow_opacity = value;
|
||
}
|
||
(LayerStyle::Satin { opacity, .. }, "opacity") => *opacity = value,
|
||
(LayerStyle::Satin { angle, .. }, "angle") => *angle = value,
|
||
(LayerStyle::Satin { distance, .. }, "distance") => {
|
||
*distance = value
|
||
}
|
||
(LayerStyle::Satin { size, .. }, "size") => *size = value,
|
||
(LayerStyle::ColorOverlay { opacity, .. }, "opacity") => {
|
||
*opacity = value
|
||
}
|
||
(LayerStyle::GradientOverlay { opacity, .. }, "opacity") => {
|
||
*opacity = value
|
||
}
|
||
(LayerStyle::GradientOverlay { angle, .. }, "angle") => {
|
||
*angle = value
|
||
}
|
||
(LayerStyle::GradientOverlay { scale, .. }, "scale") => {
|
||
*scale = value
|
||
}
|
||
(LayerStyle::PatternOverlay { opacity, .. }, "opacity") => {
|
||
*opacity = value
|
||
}
|
||
(LayerStyle::PatternOverlay { scale, .. }, "scale") => {
|
||
*scale = value
|
||
}
|
||
(LayerStyle::Stroke { size, .. }, "size") => *size = value,
|
||
(LayerStyle::Stroke { opacity, .. }, "opacity") => *opacity = value,
|
||
_ => {}
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.update_layer_style(layer_id, new_style);
|
||
}
|
||
}
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::LayerStyleUpdateBlendMode(index, mode) => {
|
||
if let Some(baseline) = self.layer_style_baseline.as_mut() {
|
||
baseline.changed = true;
|
||
}
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
if index < disc_names.len() {
|
||
let disc = disc_names[index];
|
||
let existing = styles.iter().find(|s| {
|
||
matches!(
|
||
(disc, s),
|
||
("DropShadow", LayerStyle::DropShadow { .. })
|
||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||
| ("Satin", LayerStyle::Satin { .. })
|
||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||
| ("Stroke", LayerStyle::Stroke { .. })
|
||
)
|
||
});
|
||
if let Some(style) = existing {
|
||
let mut new_style = style.clone();
|
||
match &mut new_style {
|
||
LayerStyle::DropShadow { blend_mode, .. }
|
||
| LayerStyle::InnerShadow { blend_mode, .. }
|
||
| LayerStyle::OuterGlow { blend_mode, .. }
|
||
| LayerStyle::InnerGlow { blend_mode, .. }
|
||
| LayerStyle::ColorOverlay { blend_mode, .. }
|
||
| LayerStyle::GradientOverlay { blend_mode, .. }
|
||
| LayerStyle::PatternOverlay { blend_mode, .. }
|
||
| LayerStyle::Stroke { blend_mode, .. } => *blend_mode = mode,
|
||
LayerStyle::BevelEmboss {
|
||
highlight_blend_mode,
|
||
..
|
||
} => *highlight_blend_mode = mode,
|
||
_ => {}
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.update_layer_style(layer_id, new_style);
|
||
}
|
||
}
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::LayerStyleUpdateColor(index, new_color) => {
|
||
if let Some(baseline) = self.layer_style_baseline.as_mut() {
|
||
baseline.changed = true;
|
||
}
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
if index < disc_names.len() {
|
||
let disc = disc_names[index];
|
||
let existing = styles.iter().find(|s| {
|
||
matches!(
|
||
(disc, s),
|
||
("DropShadow", LayerStyle::DropShadow { .. })
|
||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||
| ("Satin", LayerStyle::Satin { .. })
|
||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||
| ("Stroke", LayerStyle::Stroke { .. })
|
||
)
|
||
});
|
||
if let Some(style) = existing {
|
||
let mut updated_style = style.clone();
|
||
match &mut updated_style {
|
||
LayerStyle::DropShadow { color, .. }
|
||
| LayerStyle::InnerShadow { color, .. }
|
||
| LayerStyle::OuterGlow { color, .. }
|
||
| LayerStyle::InnerGlow { color, .. }
|
||
| LayerStyle::ColorOverlay { color, .. }
|
||
| LayerStyle::Satin { color, .. }
|
||
| LayerStyle::Stroke { color, .. } => *color = new_color,
|
||
LayerStyle::BevelEmboss {
|
||
highlight_color,
|
||
shadow_color,
|
||
..
|
||
} => {
|
||
if self.style_color_is_shadow {
|
||
*shadow_color = new_color;
|
||
} else {
|
||
*highlight_color = new_color;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.update_layer_style(layer_id, updated_style);
|
||
}
|
||
}
|
||
}
|
||
// Update the HSL state for the picker
|
||
let r = new_color[0] as f32 / 255.0;
|
||
let g = new_color[1] as f32 / 255.0;
|
||
let b = new_color[2] as f32 / 255.0;
|
||
let max = r.max(g).max(b);
|
||
let min = r.min(g).min(b);
|
||
let l = (max + min) / 2.0;
|
||
let s = if max == min {
|
||
0.0
|
||
} else if l > 0.5 {
|
||
(max - min) / (2.0 - max - min)
|
||
} else {
|
||
(max - min) / (max + min)
|
||
};
|
||
let h = if max == min {
|
||
0.0
|
||
} else if max == r {
|
||
((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0
|
||
} else if max == g {
|
||
((b - r) / (max - min) + 2.0) / 6.0 * 360.0
|
||
} else {
|
||
((r - g) / (max - min) + 4.0) / 6.0 * 360.0
|
||
};
|
||
self.style_color_hsl = (h, s, l);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::LayerStyleUpdateString(index, param, value) => {
|
||
if let Some(baseline) = self.layer_style_baseline.as_mut() {
|
||
baseline.changed = true;
|
||
}
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
if index < disc_names.len() {
|
||
let disc = disc_names[index];
|
||
let existing = styles.iter().find(|s| {
|
||
matches!(
|
||
(disc, s),
|
||
("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||
| ("DropShadow", LayerStyle::DropShadow { .. })
|
||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||
)
|
||
});
|
||
if let Some(style) = existing {
|
||
let mut new_style = style.clone();
|
||
match (&mut new_style, param.as_str()) {
|
||
(LayerStyle::BevelEmboss { style, .. }, "style") => *style = value,
|
||
(LayerStyle::BevelEmboss { technique, .. }, "technique") => {
|
||
*technique = value
|
||
}
|
||
(LayerStyle::BevelEmboss { direction, .. }, "direction") => {
|
||
*direction = value
|
||
}
|
||
(
|
||
LayerStyle::BevelEmboss {
|
||
highlight_blend_mode,
|
||
..
|
||
},
|
||
"highlight_blend",
|
||
) => *highlight_blend_mode = value,
|
||
(
|
||
LayerStyle::BevelEmboss {
|
||
shadow_blend_mode, ..
|
||
},
|
||
"shadow_blend",
|
||
) => *shadow_blend_mode = value,
|
||
(LayerStyle::BevelEmboss { contour, .. }, "contour") => {
|
||
*contour = value
|
||
}
|
||
_ => {}
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.update_layer_style(layer_id, new_style);
|
||
}
|
||
}
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::LayerStyleUpdateShadowColor(index, new_color) => {
|
||
if let Some(baseline) = self.layer_style_baseline.as_mut() {
|
||
baseline.changed = true;
|
||
}
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
if index < disc_names.len() {
|
||
let disc = disc_names[index];
|
||
let existing = styles.iter().find(|s| {
|
||
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
|
||
});
|
||
if let Some(style) = existing {
|
||
let mut updated_style = style.clone();
|
||
if let LayerStyle::BevelEmboss { shadow_color, .. } = &mut updated_style
|
||
{
|
||
*shadow_color = new_color;
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.update_layer_style(layer_id, updated_style);
|
||
}
|
||
}
|
||
}
|
||
let r = new_color[0] as f32 / 255.0;
|
||
let g = new_color[1] as f32 / 255.0;
|
||
let b = new_color[2] as f32 / 255.0;
|
||
let max = r.max(g).max(b);
|
||
let min = r.min(g).min(b);
|
||
let l = (max + min) / 2.0;
|
||
let s = if max == min {
|
||
0.0
|
||
} else if l > 0.5 {
|
||
(max - min) / (2.0 - max - min)
|
||
} else {
|
||
(max - min) / (max + min)
|
||
};
|
||
let h = if max == min {
|
||
0.0
|
||
} else if max == r {
|
||
((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0
|
||
} else if max == g {
|
||
((b - r) / (max - min) + 2.0) / 6.0 * 360.0
|
||
} else {
|
||
((r - g) / (max - min) + 4.0) / 6.0 * 360.0
|
||
};
|
||
self.style_color_hsl = (h, s, l);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::LayerStyleUpdateShadowBlend(index, mode) => {
|
||
if let Some(baseline) = self.layer_style_baseline.as_mut() {
|
||
baseline.changed = true;
|
||
}
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
if index < disc_names.len() {
|
||
let disc = disc_names[index];
|
||
let existing = styles.iter().find(|s| {
|
||
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
|
||
});
|
||
if let Some(style) = existing {
|
||
let mut updated_style = style.clone();
|
||
if let LayerStyle::BevelEmboss {
|
||
shadow_blend_mode, ..
|
||
} = &mut updated_style
|
||
{
|
||
*shadow_blend_mode = mode;
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.update_layer_style(layer_id, updated_style);
|
||
}
|
||
}
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::LayerStyleUpdateShadowOpacity(index, value) => {
|
||
if let Some(baseline) = self.layer_style_baseline.as_mut() {
|
||
baseline.changed = true;
|
||
}
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
if index < disc_names.len() {
|
||
let disc = disc_names[index];
|
||
let existing = styles.iter().find(|s| {
|
||
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
|
||
});
|
||
if let Some(style) = existing {
|
||
let mut updated_style = style.clone();
|
||
if let LayerStyle::BevelEmboss { shadow_opacity, .. } =
|
||
&mut updated_style
|
||
{
|
||
*shadow_opacity = value;
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.update_layer_style(layer_id, updated_style);
|
||
}
|
||
}
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::ShowStyleShadowColorPicker(idx) => {
|
||
self.show_style_color_picker = true;
|
||
self.style_color_picker_idx = idx;
|
||
self.style_color_is_shadow = true;
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 && idx < disc_names.len() {
|
||
let disc = disc_names[idx];
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
if let Some(style) = styles.iter().find(|s| {
|
||
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
|
||
}) {
|
||
let color = if let LayerStyle::BevelEmboss { shadow_color, .. } = style {
|
||
*shadow_color
|
||
} else {
|
||
[0, 0, 0, 255]
|
||
};
|
||
let r = color[0] as f32 / 255.0;
|
||
let g = color[1] as f32 / 255.0;
|
||
let b = color[2] as f32 / 255.0;
|
||
let max = r.max(g).max(b);
|
||
let min = r.min(g).min(b);
|
||
let l = (max + min) / 2.0;
|
||
let s = if max == min {
|
||
0.0
|
||
} else if l > 0.5 {
|
||
(max - min) / (2.0 - max - min)
|
||
} else {
|
||
(max - min) / (max + min)
|
||
};
|
||
let h = if max == min {
|
||
0.0
|
||
} else if max == r {
|
||
((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0
|
||
} else if max == g {
|
||
((b - r) / (max - min) + 2.0) / 6.0 * 360.0
|
||
} else {
|
||
((r - g) / (max - min) + 4.0) / 6.0 * 360.0
|
||
};
|
||
self.style_color_hsl = (h, s, l);
|
||
}
|
||
}
|
||
return Task::none();
|
||
}
|
||
Message::ShowStyleColorPicker(idx) => {
|
||
self.show_style_color_picker = true;
|
||
self.style_color_picker_idx = idx;
|
||
self.style_color_is_shadow = false;
|
||
// Initialize HSL from current color
|
||
let disc_names = [
|
||
"DropShadow",
|
||
"InnerShadow",
|
||
"OuterGlow",
|
||
"InnerGlow",
|
||
"BevelEmboss",
|
||
"Satin",
|
||
"ColorOverlay",
|
||
"GradientOverlay",
|
||
"PatternOverlay",
|
||
"Stroke",
|
||
];
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if layer_id != 0 && idx < disc_names.len() {
|
||
let styles = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_styles(layer_id);
|
||
let disc = disc_names[idx];
|
||
let color = styles
|
||
.iter()
|
||
.find(|s| {
|
||
matches!(
|
||
(disc, s),
|
||
("DropShadow", LayerStyle::DropShadow { .. })
|
||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||
| ("Satin", LayerStyle::Satin { .. })
|
||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||
| ("Stroke", LayerStyle::Stroke { .. })
|
||
)
|
||
})
|
||
.map(|s| match s {
|
||
LayerStyle::DropShadow { color, .. }
|
||
| LayerStyle::InnerShadow { color, .. }
|
||
| LayerStyle::OuterGlow { color, .. }
|
||
| LayerStyle::InnerGlow { color, .. }
|
||
| LayerStyle::ColorOverlay { color, .. }
|
||
| LayerStyle::Satin { color, .. }
|
||
| LayerStyle::Stroke { color, .. } => *color,
|
||
LayerStyle::BevelEmboss {
|
||
highlight_color, ..
|
||
} => *highlight_color,
|
||
_ => [0, 0, 0, 255],
|
||
})
|
||
.unwrap_or([0, 0, 0, 255]);
|
||
let r = color[0] as f32 / 255.0;
|
||
let g = color[1] as f32 / 255.0;
|
||
let b = color[2] as f32 / 255.0;
|
||
let max = r.max(g).max(b);
|
||
let min = r.min(g).min(b);
|
||
let l = (max + min) / 2.0;
|
||
let s = if max == min {
|
||
0.0
|
||
} else if l > 0.5 {
|
||
(max - min) / (2.0 - max - min)
|
||
} else {
|
||
(max - min) / (max + min)
|
||
};
|
||
let h = if max == min {
|
||
0.0
|
||
} else if max == r {
|
||
((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0
|
||
} else if max == g {
|
||
((b - r) / (max - min) + 2.0) / 6.0 * 360.0
|
||
} else {
|
||
((r - g) / (max - min) + 4.0) / 6.0 * 360.0
|
||
};
|
||
self.style_color_hsl = (h, s, l);
|
||
}
|
||
}
|
||
Message::HideStyleColorPicker => {
|
||
self.show_style_color_picker = false;
|
||
}
|
||
Message::OpenLayerStyleDialog => {
|
||
let document_index = self.active_doc;
|
||
let layer_id = self.documents[document_index].engine.active_layer_id();
|
||
self.layer_style_baseline = Some(LayerStyleBaseline {
|
||
document_index,
|
||
layer_id,
|
||
styles: self.documents[document_index]
|
||
.engine
|
||
.get_layer_styles(layer_id),
|
||
was_modified: self.documents[document_index].modified,
|
||
changed: false,
|
||
});
|
||
self.active_dialog = ActiveDialog::LayerStyleDialog;
|
||
self.layer_style_offset = (0.0, 0.0);
|
||
self.layer_style_dragging = false;
|
||
self.layer_style_drag_start = None;
|
||
}
|
||
Message::LayerStyleDialogDragStart(_x, _y) => {
|
||
// Drag started — begin tracking mouse movement.
|
||
// The actual offset computation uses delta from the last position.
|
||
self.layer_style_dragging = true;
|
||
}
|
||
Message::LayerStyleDialogDragMove(x, y) | Message::DockPointerMoved(x, y) => {
|
||
crate::canvas::perf::record_idle_pointer_message(true);
|
||
self.last_cursor_pos = Some((x, y));
|
||
let pointer = iced::Point::new(x, y);
|
||
if self.dock.drag.is_none() {
|
||
if let Some(header) = self.dock.header_drag {
|
||
if pointer.distance(header.origin) >= 4.0 {
|
||
self.dock.drag = Some(DockDragState {
|
||
source_pane: header.source_pane,
|
||
source_type: header.source_type,
|
||
pointer: Some(pointer),
|
||
candidate_target: None,
|
||
accepted_preview: None,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if self.dock.drag.is_some() {
|
||
let geometry = crate::dock::preview::DockGeometry {
|
||
viewport: iced::Size::new(
|
||
self.settings.window.width,
|
||
self.settings.window.height,
|
||
),
|
||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||
};
|
||
let regions = self.dock.pane_grid.layout().pane_regions(
|
||
crate::dock::preview::PANE_SPACING,
|
||
geometry.grid_bounds().size(),
|
||
);
|
||
let source_type = self.dock.drag.map(|drag| drag.source_type);
|
||
let approved = crate::dock::preview::approved_target(
|
||
pointer, geometry, ®ions,
|
||
)
|
||
.filter(|target| {
|
||
source_type.is_some_and(|source| {
|
||
crate::dock::manager::DockDropPolicy::accepts(source, target.zone)
|
||
})
|
||
});
|
||
if let Some(drag) = &mut self.dock.drag {
|
||
drag.pointer = Some(pointer);
|
||
drag.candidate_target = approved.map(|target| target.target);
|
||
drag.accepted_preview = approved.map(|target| target.preview);
|
||
}
|
||
}
|
||
if let Some(floating_drag) = self.dock.floating_drag {
|
||
let geometry = crate::dock::preview::DockGeometry {
|
||
viewport: iced::Size::new(
|
||
self.settings.window.width,
|
||
self.settings.window.height,
|
||
),
|
||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||
};
|
||
let regions = self.dock.pane_grid.layout().pane_regions(
|
||
crate::dock::preview::PANE_SPACING,
|
||
geometry.grid_bounds().size(),
|
||
);
|
||
self.dock.floating_target = crate::dock::preview::approved_target(
|
||
pointer, geometry, ®ions,
|
||
)
|
||
.filter(|target| {
|
||
crate::dock::manager::DockDropPolicy::accepts(
|
||
floating_drag.panel,
|
||
target.zone,
|
||
)
|
||
});
|
||
if let Some(current) = self
|
||
.dock
|
||
.floating
|
||
.iter()
|
||
.find(|item| item.panel == floating_drag.panel)
|
||
.map(|item| item.rect)
|
||
{
|
||
let rect = crate::dock::manager::DockRect {
|
||
x: x - floating_drag.pointer_offset.x,
|
||
y: y - floating_drag.pointer_offset.y,
|
||
..current
|
||
};
|
||
self.dock.move_floating(
|
||
floating_drag.panel,
|
||
rect,
|
||
(self.settings.window.width, self.settings.window.height),
|
||
);
|
||
}
|
||
}
|
||
if self.layer_style_dragging {
|
||
if let Some((lx, ly)) = self.layer_style_drag_start {
|
||
// Compute delta from last position and add to offset
|
||
let dx = x - lx;
|
||
let dy = y - ly;
|
||
self.layer_style_offset.0 += dx;
|
||
self.layer_style_offset.1 += dy;
|
||
}
|
||
self.layer_style_drag_start = Some((x, y));
|
||
}
|
||
}
|
||
Message::LayerStyleDialogDragEnd | Message::DockPointerReleased => {
|
||
self.layer_style_dragging = false;
|
||
self.layer_style_drag_start = None;
|
||
let mut persist_dock = self.dock.resize_persistence_dirty;
|
||
self.dock.resize_persistence_dirty = false;
|
||
if let Some(header) = self.dock.header_drag.take() {
|
||
if let Some(drag) = self
|
||
.dock
|
||
.drag
|
||
.take()
|
||
.filter(|drag| drag.source_pane == header.source_pane)
|
||
{
|
||
if let Some(target) = drag.candidate_target {
|
||
if self.dock.apply_drop(drag.source_pane, target) {
|
||
persist_dock = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(drag) = self.dock.floating_drag.take() {
|
||
if let Some(target) = self.dock.floating_target.take() {
|
||
self.dock.redock_floating_at(drag.panel, target.target);
|
||
}
|
||
persist_dock = true;
|
||
}
|
||
if persist_dock {
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
return Task::perform(async {}, |_| Message::DockReleaseCleanup);
|
||
}
|
||
Message::DockReleaseCleanup => {
|
||
self.dock.cancel_transient_drags();
|
||
}
|
||
|
||
// ── Brushes ───────────────────────────────────
|
||
Message::BrushStyleSelected(style) => {
|
||
log::info!("Brush style selected: {:?}", style);
|
||
self.tool_state.brush_style = style;
|
||
self.tool_state.active_brush_preset = None;
|
||
}
|
||
Message::BrushPresetSelected(preset) => {
|
||
log::info!("Brush preset selected: {}", preset.name);
|
||
self.tool_state.brush_style = protocol_style_from_preset(preset.style);
|
||
self.tool_state.brush_size = preset.tip.size;
|
||
self.tool_state.brush_opacity = preset.tip.opacity;
|
||
self.tool_state.brush_hardness = preset.tip.hardness;
|
||
self.tool_state.brush_spacing = preset.tip.spacing;
|
||
self.tool_state.active_brush_preset = Some(preset);
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushCategorySelected(category) => {
|
||
self.brush_category = category;
|
||
}
|
||
Message::BrushSizeChanged(size) => {
|
||
self.tool_state.brush_size = size;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushSizeRelative(delta) => {
|
||
self.tool_state.brush_size = (self.tool_state.brush_size + delta).clamp(1.0, 500.0);
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushOpacityChanged(opacity) => {
|
||
self.tool_state.brush_opacity = opacity;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushHardnessChanged(hardness) => {
|
||
self.tool_state.brush_hardness = hardness;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushFlowChanged(flow) => {
|
||
self.settings.tool_settings.brush_flow = flow;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushSpacingChanged(spacing) => {
|
||
self.tool_state.brush_spacing = spacing;
|
||
self.settings.tool_settings.brush_spacing = spacing;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushColorVariantToggled(variant) => {
|
||
self.tool_state.brush_color_variant = variant;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushVariantAmountChanged(amount) => {
|
||
self.tool_state.brush_variant_amount = amount;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushAngleChanged(angle) => {
|
||
self.tool_state.brush_angle = angle;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushSizeVarianceChanged(variance) => {
|
||
self.tool_state.brush_size_variance = variance;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushAngleVarianceChanged(variance) => {
|
||
self.tool_state.brush_angle_variance = variance;
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushCyclicColorToggled(enabled) => {
|
||
self.tool_state.brush_cyclic_color = enabled;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_cyclic_color(enabled);
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushCyclicSpeedChanged(speed) => {
|
||
self.tool_state.brush_cyclic_speed = speed;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_cyclic_speed(speed);
|
||
self.settings.update_from_tool_state(&self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::SelectionToleranceChanged(tol) => {
|
||
self.settings.tool_settings.magic_wand_tolerance = tol as f32;
|
||
self.settings.tool_settings.flood_fill_tolerance = tol as f32;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::SelectionContiguousToggled(v) => {
|
||
self.settings.tool_settings.selection_contiguous = v;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::SelectionAntiAliasToggled(v) => {
|
||
self.settings.tool_settings.selection_anti_alias = v;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::SelectionFeatherChanged(r) => {
|
||
self.settings.tool_settings.select_feather = r;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::SprayParticleSizeChanged(s) => {
|
||
self.settings.tool_settings.spray_particle_size = s;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::SprayDensityChanged(d) => {
|
||
self.settings.tool_settings.spray_density = d;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::GradientTypeChanged(t) => {
|
||
self.settings.tool_settings.gradient_type = t;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::VectorStrokeChanged(s) => {
|
||
self.settings.tool_settings.vector_stroke_width = s;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
let _ = self.settings.save();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::VectorOpacityChanged(o) => {
|
||
self.settings.tool_settings.vector_opacity = o;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
let _ = self.settings.save();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::VectorFillToggled(f) => {
|
||
self.settings.tool_settings.vector_fill = f;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
let _ = self.settings.save();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::VectorRadiusChanged(r) => {
|
||
self.settings.tool_settings.vector_radius = r;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
let _ = self.settings.save();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::VectorPointsChanged(p) => {
|
||
self.settings.tool_settings.vector_points = p;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
let _ = self.settings.save();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::VectorSidesChanged(s) => {
|
||
self.settings.tool_settings.vector_sides = s;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
let _ = self.settings.save();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::VectorAngleChanged(angle_deg) => {
|
||
self.settings.tool_settings.vector_angle = angle_deg;
|
||
crate::shape_sync::sync_shape_from_tool(self);
|
||
self.refresh_composite_if_needed();
|
||
let _ = self.settings.save();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::TextFontChanged(font) => {
|
||
self.settings.tool_settings.text_font = font.clone();
|
||
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
|
||
draft.font = font;
|
||
}
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::TextSizeChanged(size) => {
|
||
self.settings.tool_settings.text_size = size;
|
||
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
|
||
draft.size = size;
|
||
}
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::TextAngleChanged(angle) => {
|
||
self.settings.tool_settings.text_angle = angle;
|
||
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
|
||
draft.angle = angle;
|
||
}
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::TextColorChanged(color) => {
|
||
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
|
||
draft.color = color;
|
||
}
|
||
}
|
||
Message::TextAlignChanged(alignment) => {
|
||
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
|
||
draft.alignment = match alignment.as_str() {
|
||
"Center" => hcie_engine_api::TextAlignment::Center,
|
||
"Right" => hcie_engine_api::TextAlignment::Right,
|
||
"Justify" => hcie_engine_api::TextAlignment::Justify,
|
||
_ => hcie_engine_api::TextAlignment::Left,
|
||
};
|
||
}
|
||
}
|
||
Message::TextOrientationChanged(orientation) => {
|
||
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
|
||
draft.orientation = match orientation.as_str() {
|
||
"Vertical" => hcie_engine_api::TextOrientation::Vertical,
|
||
_ => hcie_engine_api::TextOrientation::Horizontal,
|
||
};
|
||
}
|
||
}
|
||
Message::TextContentChanged(content) => {
|
||
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
|
||
draft.editor = iced::widget::text_editor::Content::with_text(&content);
|
||
draft.content = content;
|
||
}
|
||
}
|
||
Message::TextEditorAction(action) => {
|
||
if let Some(draft) = self.active_document_mut().text_draft.as_mut() {
|
||
draft.editor.perform(action);
|
||
draft.content = draft.editor.text();
|
||
}
|
||
}
|
||
Message::TextCommit => {
|
||
let draft = self.active_document_mut().text_draft.take();
|
||
if let Some(draft) = draft {
|
||
if !draft.content.is_empty() {
|
||
let engine = &mut self.active_document_mut().engine;
|
||
engine.add_text(
|
||
&draft.content,
|
||
&draft.font,
|
||
draft.size,
|
||
draft.x,
|
||
draft.y,
|
||
draft.color,
|
||
draft.angle,
|
||
draft.alignment,
|
||
draft.orientation,
|
||
&[],
|
||
);
|
||
self.active_document_mut().modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
}
|
||
Message::TextAccept => {
|
||
// Alias of TextCommit for existing panel wiring.
|
||
return self.update(Message::TextCommit);
|
||
}
|
||
Message::TextCancel => {
|
||
self.active_document_mut().text_draft = None;
|
||
}
|
||
Message::ToolApplyToNewLayerToggled(v) => {
|
||
self.settings.tool_settings.tool_apply_to_new_layer = v;
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::ResetToolDefaults => {
|
||
self.settings.reset_tool_defaults();
|
||
self.settings.apply_to_tool_state(&mut self.tool_state);
|
||
let _ = self.settings.save();
|
||
}
|
||
Message::BrushImportAbr => {
|
||
log::info!("Import ABR brush requested");
|
||
let task = Task::perform(
|
||
async {
|
||
let result = rfd::AsyncFileDialog::new()
|
||
.add_filter("ABR Brush Files", &["abr"])
|
||
.add_filter("All Files", &["*"])
|
||
.set_title("Import ABR Brush")
|
||
.pick_file()
|
||
.await
|
||
.map(|handle| handle.path().to_path_buf());
|
||
match result {
|
||
Some(path) => {
|
||
let file_name = path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().to_string())
|
||
.unwrap_or_default();
|
||
match std::fs::read(&path) {
|
||
Ok(data) => {
|
||
let presets = crate::brush_import::import_abr_with_prefix(
|
||
&data, &file_name,
|
||
);
|
||
Ok(presets)
|
||
}
|
||
Err(e) => Err(format!("Failed to read file: {}", e)),
|
||
}
|
||
}
|
||
None => Err("No file selected".to_string()),
|
||
}
|
||
},
|
||
|result| Message::BrushImportAbrFile(result),
|
||
);
|
||
return task;
|
||
}
|
||
Message::BrushImportAbrFile(result) => {
|
||
match result {
|
||
Ok(presets) => {
|
||
log::info!("Imported {} brush presets", presets.len());
|
||
// Store imported presets in the tool state
|
||
self.tool_state.imported_brushes.extend(presets);
|
||
}
|
||
Err(e) => {
|
||
log::error!("Failed to import ABR brush: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Filters ───────────────────────────────────
|
||
Message::FilterSelect(filter) => {
|
||
self.dock.reopen_pane(crate::dock::state::PaneType::Filters);
|
||
self.restore_preview();
|
||
self.selected_filter = Some(filter);
|
||
self.filter_preview_active = self.documents[self.active_doc]
|
||
.engine
|
||
.active_layer_is_editable()
|
||
&& self.begin_preview();
|
||
self.filter_params = serde_json::json!({});
|
||
}
|
||
Message::FilterParamChanged(key, value) => {
|
||
if let serde_json::Value::Object(ref mut map) = self.filter_params {
|
||
if let serde_json::Value::Object(values) = value {
|
||
map.extend(values);
|
||
} else {
|
||
map.insert(key, value);
|
||
}
|
||
}
|
||
if self.filter_preview_active && self.reset_preview_to_baseline() {
|
||
let Some(filter) = self.selected_filter else {
|
||
return Task::none();
|
||
};
|
||
let filter_id = filter_type_to_id(filter);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter_preview(filter_id, self.filter_params.clone());
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
Message::FilterApply => {
|
||
if let Some(filter) = self.selected_filter {
|
||
if !self.documents[self.active_doc]
|
||
.engine
|
||
.active_layer_is_editable()
|
||
{
|
||
log::warn!("filter apply blocked for locked, group, or mask layer");
|
||
return Task::none();
|
||
}
|
||
// Capture the current layer pixels before applying so the
|
||
// operation can be undone from the History panel.
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let active_idx = doc.engine.active_layer_index();
|
||
let before_pixels = doc.engine.get_active_layer_pixels();
|
||
let filter_name = filter_label(filter);
|
||
|
||
self.reset_preview_to_baseline();
|
||
let filter_id = filter_type_to_id(filter);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter(filter_id, self.filter_params.clone());
|
||
// Always push a history snapshot after applying the filter so
|
||
// the operation is visible in the History panel and undo works.
|
||
if let Some(before) = before_pixels {
|
||
let history_name = format!("Apply {}", filter_name);
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.engine
|
||
.push_active_layer_snapshot(before, active_idx, &history_name);
|
||
}
|
||
// Refresh cached history list so the new entry appears.
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let history_len = doc.engine.history_len();
|
||
doc.cached_history = (0..history_len)
|
||
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
|
||
.collect();
|
||
doc.history_current = doc.engine.history_current();
|
||
self.preview_baseline = None;
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
self.filter_preview_active = false;
|
||
self.selected_filter = None;
|
||
self.filter_params = serde_json::json!({});
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
Message::FilterReset => {
|
||
self.filter_params = serde_json::json!({});
|
||
if self.filter_preview_active && self.reset_preview_to_baseline() {
|
||
if let Some(filter) = self.selected_filter {
|
||
self.documents[self.active_doc].engine.apply_filter_preview(
|
||
filter_type_to_id(filter),
|
||
self.filter_params.clone(),
|
||
);
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
|
||
Message::FilterPreviewToggle(enabled) => {
|
||
self.filter_preview_active = enabled;
|
||
if enabled {
|
||
if self.begin_preview() {
|
||
let Some(filter) = self.selected_filter else {
|
||
return Task::none();
|
||
};
|
||
let filter_id = filter_type_to_id(filter);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter_preview(filter_id, self.filter_params.clone());
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
} else {
|
||
self.restore_preview();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
|
||
Message::FilterPreviewApply => {
|
||
return self.update(Message::FilterApply);
|
||
}
|
||
|
||
Message::FilterPreviewRestore => {
|
||
if self.filter_preview_active {
|
||
self.restore_preview();
|
||
self.refresh_composite_if_needed();
|
||
self.filter_preview_active = false;
|
||
self.selected_filter = None;
|
||
self.filter_params = serde_json::json!({});
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
|
||
Message::FilterCategoryToggle(category) => {
|
||
let entry = self
|
||
.filter_categories_expanded
|
||
.entry(category)
|
||
.or_insert(true);
|
||
*entry = !*entry;
|
||
}
|
||
|
||
// ── Dialogs ───────────────────────────────────
|
||
Message::DialogOpen(dialog) => {
|
||
if matches!(
|
||
dialog,
|
||
ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation
|
||
) {
|
||
self.dialog_brightness = 0.0;
|
||
self.dialog_contrast = 0.0;
|
||
self.dialog_hue = 0.0;
|
||
self.dialog_saturation = 0.0;
|
||
self.dialog_lightness = 0.0;
|
||
self.begin_preview();
|
||
}
|
||
self.active_dialog = dialog;
|
||
}
|
||
Message::DialogClose => {
|
||
if matches!(self.active_dialog, ActiveDialog::SvgEditor) {
|
||
return self.update(Message::SvgEditorCancel);
|
||
}
|
||
if matches!(
|
||
self.active_dialog,
|
||
ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation
|
||
) && self.restore_preview()
|
||
{
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
if matches!(self.active_dialog, ActiveDialog::SaveError) {
|
||
self.save_error_state = None;
|
||
}
|
||
// ExpandCanvas -> cancel expansion; paste proceeds as-is
|
||
// (centered on the unchanged canvas, cropping on commit, matching
|
||
// EGUI's "paste proceeds as-is" cancel behavior).
|
||
if matches!(self.active_dialog, ActiveDialog::ExpandCanvas { .. }) {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let as_new_layer = doc.pending_paste_as_new_layer;
|
||
if let Some((pixels, w, h)) = doc.pending_paste.take() {
|
||
doc.pending_paste_as_new_layer = false;
|
||
let canvas_w = doc.engine.canvas_width();
|
||
let canvas_h = doc.engine.canvas_height();
|
||
if as_new_layer {
|
||
// Paste as new layer: create layer with centered (possibly clipped) pixels.
|
||
let centered = selection::clipboard::centered_layer_pixels(
|
||
&pixels, w, h, canvas_w, canvas_h,
|
||
);
|
||
let id = doc.engine.add_layer("Pasted Layer");
|
||
doc.engine.set_layer_pixels(id, centered);
|
||
doc.engine.set_active_layer(id);
|
||
doc.modified = true;
|
||
self.refresh_composite_if_needed();
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.pending_document_close = None;
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
// Standard paste: enter transform mode.
|
||
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
|
||
doc.transform_selection_baseline =
|
||
doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||
doc.transform_source = None;
|
||
doc.transform_preview_base = Some(doc.composite_raw.clone());
|
||
doc.selection_transform = selection::clipboard::centered_transform(
|
||
pixels, w, h, canvas_w, canvas_h,
|
||
);
|
||
render_transform_preview(doc, None);
|
||
}
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.pending_document_close = None;
|
||
return Task::none();
|
||
}
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.pending_document_close = None;
|
||
}
|
||
Message::DialogCloseSave => {
|
||
// SaveError -> save to suggested path
|
||
if matches!(self.active_dialog, ActiveDialog::SaveError) {
|
||
if let Some(state) = self.save_error_state.take() {
|
||
let path = state.suggestion.clone();
|
||
let op = state.operation.clone();
|
||
match self.save_document_to(self.active_doc, &path) {
|
||
Ok(()) => {
|
||
self.finish_successful_save(self.active_doc, path);
|
||
log::info!("{} saved to suggested path", op);
|
||
}
|
||
Err(e) => {
|
||
log::error!("{} to suggested path failed: {}", op, e);
|
||
self.save_error_state =
|
||
Some(crate::dialogs::save_error::SaveErrorState {
|
||
operation: op,
|
||
path: path.clone(),
|
||
error: e.clone(),
|
||
suggestion: Self::suggest_alternative_path(&path),
|
||
});
|
||
self.active_dialog = ActiveDialog::SaveError;
|
||
return Task::none();
|
||
}
|
||
}
|
||
}
|
||
self.active_dialog = ActiveDialog::None;
|
||
return Task::none();
|
||
}
|
||
// ExpandCanvas -> expand document to fit paste, then create transform
|
||
if let ActiveDialog::ExpandCanvas {
|
||
paste_w, paste_h, ..
|
||
} = self.active_dialog
|
||
{
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let as_new_layer = doc.pending_paste_as_new_layer;
|
||
doc.engine.resize_canvas(paste_w, paste_h);
|
||
doc.engine.selection_clear();
|
||
set_document_selection_mask(doc, None);
|
||
let canvas_w = doc.engine.canvas_width();
|
||
let canvas_h = doc.engine.canvas_height();
|
||
if let Some((pixels, w, h)) = doc.pending_paste.take() {
|
||
doc.pending_paste_as_new_layer = false;
|
||
if as_new_layer {
|
||
// Paste as new layer: create layer with centered pixels on expanded canvas.
|
||
let centered = selection::clipboard::centered_layer_pixels(
|
||
&pixels, w, h, canvas_w, canvas_h,
|
||
);
|
||
let id = doc.engine.add_layer("Pasted Layer");
|
||
doc.engine.set_layer_pixels(id, centered);
|
||
doc.engine.set_active_layer(id);
|
||
doc.modified = true;
|
||
} else {
|
||
// Standard paste: enter transform mode.
|
||
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
|
||
doc.transform_selection_baseline =
|
||
doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||
doc.transform_source = None;
|
||
doc.transform_preview_base = Some(doc.composite_raw.clone());
|
||
doc.selection_transform = selection::clipboard::centered_transform(
|
||
pixels, w, h, canvas_w, canvas_h,
|
||
);
|
||
render_transform_preview(doc, None);
|
||
}
|
||
}
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
// SVG editor -> save and close
|
||
if matches!(self.active_dialog, ActiveDialog::SvgEditor) {
|
||
return self.update(Message::SvgEditorSave);
|
||
}
|
||
// Default: save-before-close
|
||
if let Some(path) = self.documents[self.active_doc].source_path.clone() {
|
||
match self.save_document_to(self.active_doc, &path) {
|
||
Ok(()) => {
|
||
self.finish_successful_save(self.active_doc, path);
|
||
return self.continue_close_after_save();
|
||
}
|
||
Err(error) => {
|
||
log::error!("Failed to save before close: {error}");
|
||
self.save_error_state =
|
||
Some(crate::dialogs::save_error::SaveErrorState {
|
||
operation: "Save".to_string(),
|
||
path: path.clone(),
|
||
error: error.clone(),
|
||
suggestion: Self::suggest_alternative_path(&path),
|
||
});
|
||
self.active_dialog = ActiveDialog::SaveError;
|
||
return Task::none();
|
||
}
|
||
}
|
||
}
|
||
self.pending_file_operation =
|
||
Some(PendingFileOperation::SaveAs { close_after: true });
|
||
self.active_dialog = ActiveDialog::None;
|
||
return self.update(Message::SaveFileAs);
|
||
}
|
||
Message::DialogCloseDiscard => {
|
||
// SVG editor -> close without saving
|
||
if matches!(self.active_dialog, ActiveDialog::SvgEditor) {
|
||
return self.update(Message::SvgEditorCancel);
|
||
}
|
||
// SaveError -> retry
|
||
if matches!(self.active_dialog, ActiveDialog::SaveError) {
|
||
self.save_error_state = None;
|
||
self.active_dialog = ActiveDialog::None;
|
||
return self.update(Message::SaveFile);
|
||
}
|
||
self.documents[self.active_doc].modified = false;
|
||
return self.continue_close_after_save();
|
||
}
|
||
|
||
// ── New Image Dialog ──────────────────────────
|
||
Message::DialogNewImageName(name) => {
|
||
self.dialog_new_name = name;
|
||
}
|
||
Message::DialogNewImageWidth(w) => {
|
||
self.dialog_new_width = w;
|
||
}
|
||
Message::DialogNewImageHeight(h) => {
|
||
self.dialog_new_height = h;
|
||
}
|
||
Message::DialogNewImageTransparent(t) => {
|
||
self.dialog_new_transparent = t;
|
||
}
|
||
Message::DialogNewImagePreset(w, h) => {
|
||
self.dialog_new_width = w;
|
||
self.dialog_new_height = h;
|
||
}
|
||
Message::DialogNewImageCreate => {
|
||
let w = self.dialog_new_width;
|
||
let h = self.dialog_new_height;
|
||
// Guard against 0×N documents that would crash wgpu texture allocation.
|
||
if w == 0 || h == 0 {
|
||
log::warn!(
|
||
"Refusing to create a {}×{} document: dimensions must be at least 1",
|
||
w,
|
||
h
|
||
);
|
||
return Task::none();
|
||
}
|
||
let name = self.dialog_new_name.clone();
|
||
let transparent = self.dialog_new_transparent;
|
||
let mut engine = Engine::new_with_options(&name, w, h, transparent);
|
||
engine.pre_tile_all_layers();
|
||
let composite_raw = engine.get_composite_pixels();
|
||
let cached_layers = engine.layer_infos();
|
||
let history_len = engine.history_len();
|
||
let cached_history: Vec<(usize, String)> = (0..history_len)
|
||
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
||
.collect();
|
||
let history_current = engine.history_current();
|
||
let doc = IcedDocument {
|
||
engine,
|
||
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
|
||
composite_raw.clone(),
|
||
),
|
||
composite_raw,
|
||
name,
|
||
source_path: None,
|
||
modified: false,
|
||
zoom: 1.0,
|
||
pan_offset: Vector::ZERO,
|
||
cached_layers,
|
||
cached_history,
|
||
history_current,
|
||
selection_rect: None,
|
||
vector_draw: None,
|
||
pane_size: (800.0, 600.0),
|
||
render_generation: 0,
|
||
dirty_region: std::cell::RefCell::new(None),
|
||
full_upload: std::cell::RefCell::new(true),
|
||
selection_transform: None,
|
||
transform_drag_handle: TransformHandle::None,
|
||
transform_drag_start: None,
|
||
transform_original: None,
|
||
transform_source: None,
|
||
transform_layer_baseline: None,
|
||
transform_selection_baseline: None,
|
||
transform_preview_base: None,
|
||
internal_clipboard: None,
|
||
pending_paste: None,
|
||
pending_paste_as_new_layer: false,
|
||
selection_mask: None,
|
||
selection_texture: None,
|
||
selection_model_dirty: false,
|
||
selection_mask_dirty: std::cell::Cell::new(false),
|
||
selection_bounds: None,
|
||
selection_history: Vec::new(),
|
||
selection_history_marker: 0,
|
||
crop_state: CropState::default(),
|
||
lasso_points: Vec::new(),
|
||
polygon_points: Vec::new(),
|
||
gradient_drag: None,
|
||
vision_rect: None,
|
||
text_draft: None,
|
||
selection_mode: selection::SelectionMode::Replace,
|
||
saved_selection_mask: None,
|
||
quick_mask: false,
|
||
selected_vector_shape: None,
|
||
vector_cycle_index: 0,
|
||
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
|
||
vector_drag_preview: None,
|
||
rotation_snap_active: false,
|
||
rotation_snap_angle: 0.0,
|
||
resize_snap_active: false,
|
||
layer_thumbnails: std::collections::HashMap::new(),
|
||
pending_thumbnail_layers: std::collections::HashSet::new(),
|
||
thumb_gen: 0,
|
||
};
|
||
if self.show_welcome && self.documents.len() == 1 {
|
||
// Replace the placeholder empty document created for the
|
||
// welcome screen instead of appending a second tab.
|
||
self.documents = vec![doc];
|
||
self.active_doc = 0;
|
||
} else {
|
||
self.documents.push(doc);
|
||
self.active_doc = self.documents.len() - 1;
|
||
}
|
||
self.show_welcome = false;
|
||
self.active_dialog = ActiveDialog::None;
|
||
}
|
||
|
||
// ── Adjustments Dialog ────────────────────────
|
||
Message::AdjBrightness(v) => {
|
||
self.dialog_brightness = v;
|
||
let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast});
|
||
self.reset_preview_to_baseline();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter_preview("brightness_contrast", params);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::AdjContrast(v) => {
|
||
self.dialog_contrast = v;
|
||
let params =
|
||
serde_json::json!({"brightness": self.dialog_brightness, "contrast": v});
|
||
self.reset_preview_to_baseline();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter_preview("brightness_contrast", params);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::AdjHue(v) => {
|
||
self.dialog_hue = v;
|
||
let params = serde_json::json!({"hue": v, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": self.dialog_lightness});
|
||
self.reset_preview_to_baseline();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter_preview("hue_saturation", params);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::AdjSaturation(v) => {
|
||
self.dialog_saturation = v;
|
||
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + v / 100.0, "lightness": self.dialog_lightness});
|
||
self.reset_preview_to_baseline();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter_preview("hue_saturation", params);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::AdjLightness(v) => {
|
||
self.dialog_lightness = v;
|
||
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": v});
|
||
self.reset_preview_to_baseline();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter_preview("hue_saturation", params);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::AdjApply => {
|
||
// Apply the adjustment permanently (not just preview)
|
||
let params = match &self.active_dialog {
|
||
ActiveDialog::BrightnessContrast => {
|
||
serde_json::json!({"brightness": self.dialog_brightness, "contrast": self.dialog_contrast})
|
||
}
|
||
ActiveDialog::HueSaturation => {
|
||
serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": self.dialog_lightness})
|
||
}
|
||
_ => serde_json::json!({}),
|
||
};
|
||
|
||
self.reset_preview_to_baseline();
|
||
|
||
let filter_id = match &self.active_dialog {
|
||
ActiveDialog::BrightnessContrast => "brightness_contrast",
|
||
ActiveDialog::HueSaturation => "hue_saturation",
|
||
_ => "",
|
||
};
|
||
|
||
if !filter_id.is_empty() {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.apply_filter(filter_id, params);
|
||
self.preview_baseline = None;
|
||
self.documents[self.active_doc].modified = true;
|
||
}
|
||
|
||
self.refresh_composite_if_needed();
|
||
self.active_dialog = ActiveDialog::None;
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
// ── Selection Dialog ──────────────────────────
|
||
Message::SelectionOpValue(v) => {
|
||
self.dialog_selection_value = v;
|
||
}
|
||
Message::SelectionOpApply => {
|
||
let v = self.dialog_selection_value as u32;
|
||
match &self.active_dialog {
|
||
ActiveDialog::SelectionOp(op) => match *op {
|
||
"Grow" => self.documents[self.active_doc].engine.selection_grow(v),
|
||
"Shrink" => self.documents[self.active_doc].engine.selection_shrink(v),
|
||
"Feather" => self.documents[self.active_doc]
|
||
.engine
|
||
.selection_feather(v as f32),
|
||
"Border" => self.documents[self.active_doc].engine.selection_border(v),
|
||
"Smooth" => self.documents[self.active_doc].engine.selection_smooth(v),
|
||
"Erode" => self.documents[self.active_doc].engine.selection_shrink(v),
|
||
"Fade" => self.documents[self.active_doc]
|
||
.engine
|
||
.selection_feather(v as f32),
|
||
_ => {}
|
||
},
|
||
_ => {}
|
||
}
|
||
// Selection operations mutate the existing mask in place, so force overlay cache
|
||
// synchronization before the next marching-ants frame.
|
||
self.documents[self.active_doc].selection_model_dirty = true;
|
||
self.refresh_composite_if_needed();
|
||
self.active_dialog = ActiveDialog::None;
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
// ── Image Size Dialog ────────────────────────
|
||
Message::DialogImageSizeWidth(w) => {
|
||
self.dialog_image_size_width = w;
|
||
if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 {
|
||
self.dialog_image_size_height =
|
||
(w as f32 / self.dialog_image_size_original_ratio) as u32;
|
||
}
|
||
}
|
||
Message::DialogImageSizeHeight(h) => {
|
||
self.dialog_image_size_height = h;
|
||
if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 {
|
||
self.dialog_image_size_width =
|
||
(h as f32 * self.dialog_image_size_original_ratio) as u32;
|
||
}
|
||
}
|
||
Message::DialogImageSizeConstrain(c) => {
|
||
self.dialog_image_size_constrain = c;
|
||
}
|
||
Message::DialogImageSizeApply => {
|
||
let w = self.dialog_image_size_width.max(1);
|
||
let h = self.dialog_image_size_height.max(1);
|
||
self.documents[self.active_doc].engine.resize_canvas(w, h);
|
||
self.documents[self.active_doc].engine.selection_clear();
|
||
set_document_selection_mask(&mut self.documents[self.active_doc], None);
|
||
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
||
self.refresh_composite_if_needed();
|
||
self.documents[self.active_doc].full_upload.replace(true);
|
||
self.active_dialog = ActiveDialog::None;
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
// ── Canvas Size Dialog ───────────────────────
|
||
Message::DialogCanvasSizeWidth(w) => {
|
||
self.dialog_canvas_size_width = w;
|
||
}
|
||
Message::DialogCanvasSizeHeight(h) => {
|
||
self.dialog_canvas_size_height = h;
|
||
}
|
||
Message::DialogCanvasSizeAnchor(r, c) => {
|
||
self.dialog_canvas_size_anchor = (r, c);
|
||
}
|
||
Message::DialogCanvasSizeApply => {
|
||
let new_w = self.dialog_canvas_size_width.max(1);
|
||
let new_h = self.dialog_canvas_size_height.max(1);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.resize_canvas(new_w, new_h);
|
||
self.documents[self.active_doc].engine.selection_clear();
|
||
set_document_selection_mask(&mut self.documents[self.active_doc], None);
|
||
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
||
self.refresh_composite_if_needed();
|
||
self.documents[self.active_doc].full_upload.replace(true);
|
||
self.active_dialog = ActiveDialog::None;
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
// ── Selection tools ───────────────────────────
|
||
Message::SelectionDragStart(x, y) => {
|
||
self.documents[self.active_doc].selection_rect = Some((x, y, x, y));
|
||
}
|
||
Message::SelectionDragMove(x, y) => {
|
||
if let Some((x0, y0, _, _)) = self.documents[self.active_doc].selection_rect {
|
||
self.documents[self.active_doc].selection_rect = Some((x0, y0, x, y));
|
||
}
|
||
}
|
||
Message::SelectionDragEnd => {
|
||
if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect {
|
||
let sx = x0.min(x1) as u32;
|
||
let sy = y0.min(y1) as u32;
|
||
let ex = x0.max(x1) as u32;
|
||
let ey = y0.max(y1) as u32;
|
||
if ex > sx && ey > sy {
|
||
let previous = self.documents[self.active_doc]
|
||
.engine
|
||
.get_selection_mask()
|
||
.map(ToOwned::to_owned);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.create_selection_rect(sx, sy, ex, ey);
|
||
self.combine_current_selection(previous);
|
||
// Clear the drag-preview rect so the blue rectangle
|
||
// disappears and the marching ants overlay takes over.
|
||
self.documents[self.active_doc].selection_rect = None;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
// Clear selection rect if no valid selection was made
|
||
self.documents[self.active_doc].selection_rect = None;
|
||
}
|
||
Message::SelectAll => {
|
||
let previous = self.documents[self.active_doc]
|
||
.engine
|
||
.get_selection_mask()
|
||
.map(ToOwned::to_owned);
|
||
let current_marker = self.documents[self.active_doc].engine.history_current();
|
||
if current_marker != self.documents[self.active_doc].selection_history_marker {
|
||
self.documents[self.active_doc].selection_history.clear();
|
||
self.documents[self.active_doc].selection_history_marker = current_marker;
|
||
}
|
||
self.documents[self.active_doc]
|
||
.selection_history
|
||
.push(previous);
|
||
|
||
self.documents[self.active_doc].engine.selection_all();
|
||
self.documents[self.active_doc].selection_model_dirty = true;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
Message::Deselect => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let previous = doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||
let current_marker = doc.engine.history_current();
|
||
if current_marker != doc.selection_history_marker {
|
||
doc.selection_history.clear();
|
||
doc.selection_history_marker = current_marker;
|
||
}
|
||
doc.selection_history.push(previous);
|
||
|
||
doc.selection_rect = None;
|
||
doc.selection_bounds = None;
|
||
doc.selection_transform = None;
|
||
doc.transform_drag_handle = TransformHandle::None;
|
||
doc.engine.selection_clear();
|
||
set_document_selection_mask(doc, None);
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::SelectInverse => {
|
||
self.documents[self.active_doc].engine.selection_invert();
|
||
self.documents[self.active_doc].selection_model_dirty = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::SelectionModeChanged(mode) => {
|
||
self.documents[self.active_doc].selection_mode = mode;
|
||
}
|
||
Message::SelectionSave => {
|
||
self.documents[self.active_doc].saved_selection_mask = self.documents
|
||
[self.active_doc]
|
||
.engine
|
||
.get_selection_mask()
|
||
.map(ToOwned::to_owned);
|
||
}
|
||
Message::SelectionLoad => {
|
||
if let Some(mask) = self.documents[self.active_doc].saved_selection_mask.clone() {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_selection_mask(mask);
|
||
self.documents[self.active_doc].selection_model_dirty = true;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
Message::QuickMaskToggle => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.quick_mask = !doc.quick_mask;
|
||
}
|
||
Message::ClearPixels => {
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.clear_selection_pixels();
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
|
||
// ── Selection transform ────────────────────────
|
||
Message::TransformDragStart(x, y) => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
if let Some(ref tr) = doc.selection_transform {
|
||
let original = tr.clone();
|
||
doc.transform_drag_start = Some((x, y));
|
||
doc.transform_original = Some(original);
|
||
}
|
||
}
|
||
Message::TransformDragMove(x, y) => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let canvas_w = doc.engine.canvas_width();
|
||
let canvas_h = doc.engine.canvas_height();
|
||
let previous_preview_bounds = doc
|
||
.selection_transform
|
||
.as_ref()
|
||
.and_then(|tr| preview_bounds(tr, canvas_w, canvas_h));
|
||
if let (Some(start), Some(ref mut tr)) =
|
||
(doc.transform_drag_start, &mut doc.selection_transform)
|
||
{
|
||
let dx = x - start.0;
|
||
let dy = y - start.1;
|
||
|
||
match doc.transform_drag_handle {
|
||
TransformHandle::Move => {
|
||
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y + dy);
|
||
doc.transform_drag_start = Some((x, y));
|
||
}
|
||
TransformHandle::TopLeft
|
||
| TransformHandle::TopRight
|
||
| TransformHandle::BottomLeft
|
||
| TransformHandle::BottomRight
|
||
| TransformHandle::Top
|
||
| TransformHandle::Bottom
|
||
| TransformHandle::Left
|
||
| TransformHandle::Right => {
|
||
tr.resize_from_delta(doc.transform_drag_handle, dx, dy);
|
||
doc.transform_drag_start = Some((x, y));
|
||
}
|
||
TransformHandle::Rotate => {
|
||
if let Some(original) = doc.transform_original.as_ref() {
|
||
tr.rotation = selection::transform::rotation_from_drag(
|
||
original,
|
||
start,
|
||
(x, y),
|
||
);
|
||
}
|
||
}
|
||
TransformHandle::None => {}
|
||
}
|
||
tr.sanitize_geometry(self.modifiers.shift());
|
||
}
|
||
let doc = &mut self.documents[self.active_doc];
|
||
render_transform_preview(doc, previous_preview_bounds);
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Message::TransformDragEnd => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.transform_drag_start = None;
|
||
doc.transform_original = None;
|
||
}
|
||
Message::TransformApply => {
|
||
let tr_taken = self.documents[self.active_doc].selection_transform.take();
|
||
if let Some(tr) = tr_taken {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let canvas_w = doc.engine.canvas_width();
|
||
let canvas_h = doc.engine.canvas_height();
|
||
let was_move = doc.transform_source.is_some();
|
||
let before_pixels = doc
|
||
.transform_layer_baseline
|
||
.take()
|
||
.or_else(|| doc.engine.get_active_layer_pixels());
|
||
let Some(before_pixels) = before_pixels else {
|
||
return Task::none();
|
||
};
|
||
apply_transform_to_layer(
|
||
&mut doc.engine,
|
||
&tr,
|
||
canvas_w,
|
||
canvas_h,
|
||
before_pixels,
|
||
if was_move {
|
||
"Move Selection"
|
||
} else {
|
||
"Transform Apply"
|
||
},
|
||
);
|
||
doc.engine.selection_clear();
|
||
set_document_selection_mask(doc, None);
|
||
doc.transform_drag_handle = TransformHandle::None;
|
||
doc.transform_drag_start = None;
|
||
doc.transform_original = None;
|
||
doc.transform_source = None;
|
||
doc.transform_selection_baseline = None;
|
||
doc.transform_preview_base = None;
|
||
doc.modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
Message::TransformCancel => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
log::debug!(
|
||
"[TransformCancel] begin restore_layer={}, restore_selection={}",
|
||
doc.transform_source.is_some() && doc.transform_layer_baseline.is_some(),
|
||
doc.transform_selection_baseline.is_some()
|
||
);
|
||
let original_layer = doc.transform_layer_baseline.take();
|
||
if doc.transform_source.is_some() {
|
||
if let Some(original_layer) = original_layer {
|
||
doc.engine.set_active_layer_pixels(original_layer);
|
||
}
|
||
}
|
||
if let Some(mask) = doc.transform_selection_baseline.take() {
|
||
doc.engine.set_selection_mask(mask.clone());
|
||
set_document_selection_mask(doc, Some(mask));
|
||
}
|
||
doc.selection_transform = None;
|
||
doc.transform_source = None;
|
||
doc.transform_original = None;
|
||
doc.transform_drag_start = None;
|
||
doc.transform_drag_handle = TransformHandle::None;
|
||
doc.transform_preview_base = None;
|
||
self.refresh_composite_if_needed();
|
||
log::debug!("[TransformCancel] end without history snapshot");
|
||
}
|
||
|
||
// ── Vector tools ──────────────────────────────
|
||
Message::VectorDrawStart(x, y) => {
|
||
self.documents[self.active_doc].vector_draw = Some(((x, y), (x, y)));
|
||
}
|
||
Message::VectorDrawMove(x, y) => {
|
||
if let Some((start, _)) = self.documents[self.active_doc].vector_draw {
|
||
self.documents[self.active_doc].vector_draw = Some((start, (x, y)));
|
||
}
|
||
}
|
||
Message::VectorDrawEnd => {
|
||
if let Some(((x0, y0), (x1, y1))) =
|
||
self.documents[self.active_doc].vector_draw.take()
|
||
{
|
||
// `add_vector_shape` safely auto-creates a vector layer from a non-vector
|
||
// layer, but would otherwise append to a locked active vector layer.
|
||
let active_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let active_is_locked_vector = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_info(active_id)
|
||
.map(|info| {
|
||
info.locked && info.layer_type == hcie_engine_api::LayerType::Vector
|
||
})
|
||
.unwrap_or(false);
|
||
if active_is_locked_vector {
|
||
let safe_source = self.documents[self.active_doc]
|
||
.engine
|
||
.layer_infos()
|
||
.into_iter()
|
||
.find(|info| {
|
||
!info.locked
|
||
&& !matches!(
|
||
info.layer_type,
|
||
hcie_engine_api::LayerType::Vector
|
||
| hcie_engine_api::LayerType::Group
|
||
| hcie_engine_api::LayerType::Mask
|
||
)
|
||
})
|
||
.map(|info| info.id);
|
||
let Some(safe_source) = safe_source else {
|
||
return Task::none();
|
||
};
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_active_layer(safe_source);
|
||
}
|
||
// Use cached catalog snapshot if available; otherwise read once from the
|
||
// active document's shape catalog. `cached_custom_shapes` is populated/updated
|
||
// by `RefreshCustomShapes` so drawing remains consistent with the panel.
|
||
let custom_shapes: Vec<(String, String)> =
|
||
if self.cached_custom_shapes.is_empty() {
|
||
self.documents
|
||
.get(self.active_doc)
|
||
.map(|d| {
|
||
d.engine
|
||
.shape_catalog
|
||
.all()
|
||
.iter()
|
||
.map(|e| (e.kind.clone(), e.svg.clone()))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
} else {
|
||
self.cached_custom_shapes.clone()
|
||
};
|
||
|
||
let engine = &mut self.documents[self.active_doc].engine;
|
||
let color = self.fg_color;
|
||
let stroke = self.settings.tool_settings.vector_stroke_width;
|
||
let fill = self.settings.tool_settings.vector_fill;
|
||
let opacity = self.settings.tool_settings.vector_opacity;
|
||
let svg_angle = self.settings.tool_settings.vector_angle.to_radians();
|
||
let radius = self.settings.tool_settings.vector_radius;
|
||
let points = self.settings.tool_settings.vector_points;
|
||
let sides = self.settings.tool_settings.vector_sides;
|
||
let fill_color = self.bg_color;
|
||
|
||
let shape = match self.tool_state.active_tool {
|
||
Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect {
|
||
name: String::new(),
|
||
x1: x0.min(x1),
|
||
y1: y0.min(y1),
|
||
x2: x0.max(x1),
|
||
y2: y0.max(y1),
|
||
stroke,
|
||
color,
|
||
fill,
|
||
fill_color,
|
||
radius,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
}),
|
||
Tool::VectorCircle => Some(hcie_engine_api::VectorShape::Circle {
|
||
name: String::new(),
|
||
x1: x0.min(x1),
|
||
y1: y0.min(y1),
|
||
x2: x0.max(x1),
|
||
y2: y0.max(y1),
|
||
stroke,
|
||
color,
|
||
fill,
|
||
fill_color,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
}),
|
||
Tool::VectorLine => Some(hcie_engine_api::VectorShape::Line {
|
||
name: String::new(),
|
||
x1: x0,
|
||
y1: y0,
|
||
x2: x1,
|
||
y2: y1,
|
||
stroke,
|
||
color,
|
||
angle: 0.0,
|
||
cap_start: hcie_engine_api::LineCap::Round,
|
||
cap_end: hcie_engine_api::LineCap::Round,
|
||
opacity,
|
||
hardness: 0.5,
|
||
}),
|
||
Tool::VectorArrow => {
|
||
let mut p = std::collections::HashMap::new();
|
||
p.insert("thick".to_string(), 0.0);
|
||
match hcie_engine_api::create_vector_shape("arrow", x0, y0, x1, y1, &p)
|
||
{
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: x0,
|
||
y1: y0,
|
||
x2: x1,
|
||
y2: y1,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorStar => {
|
||
let mut p = std::collections::HashMap::new();
|
||
p.insert("points".to_string(), points as f32);
|
||
p.insert("inner_radius".to_string(), 0.5);
|
||
let (sx1, sy1, sx2, sy2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"star", sx1, sy1, sx2, sy2, &p,
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: sx1,
|
||
y1: sy1,
|
||
x2: sx2,
|
||
y2: sy2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorPolygon => Some(hcie_engine_api::VectorShape::Polygon {
|
||
name: String::new(),
|
||
x1: x0.min(x1),
|
||
y1: y0.min(y1),
|
||
x2: x0.max(x1),
|
||
y2: y0.max(y1),
|
||
stroke,
|
||
color,
|
||
fill,
|
||
fill_color,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
sides,
|
||
}),
|
||
Tool::VectorRhombus => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"rhombus",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorCylinder => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"cylinder",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorHeart => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"heart",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorBubble => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"bubble",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorGear => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"gear",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorCross => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"cross",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorCrescent => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"crescent",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorBolt => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"bolt",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::VectorArrow4 => {
|
||
let (nx1, ny1, nx2, ny2) =
|
||
(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||
match hcie_engine_api::create_vector_shape(
|
||
"arrow4",
|
||
nx1,
|
||
ny1,
|
||
nx2,
|
||
ny2,
|
||
&std::collections::HashMap::new(),
|
||
) {
|
||
hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => {
|
||
Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
kind,
|
||
svg,
|
||
x1: nx1,
|
||
y1: ny1,
|
||
x2: nx2,
|
||
y2: ny2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
})
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
Tool::CustomShape(idx) => {
|
||
let entry = custom_shapes.get(idx as usize);
|
||
match entry {
|
||
Some((kind, svg)) => Some(hcie_engine_api::VectorShape::SvgShape {
|
||
name: String::new(),
|
||
x1: x0.min(x1),
|
||
y1: y0.min(y1),
|
||
x2: x0.max(x1),
|
||
y2: y0.max(y1),
|
||
kind: kind.clone(),
|
||
svg: svg.clone(),
|
||
stroke,
|
||
color,
|
||
fill_color: color,
|
||
fill: true,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness: 0.5,
|
||
}),
|
||
None => None,
|
||
}
|
||
}
|
||
_ => None,
|
||
};
|
||
|
||
if let Some(mut shape) = shape {
|
||
// For arrow shapes, derive the angle from the drag
|
||
// direction so the arrow points along the drawn line.
|
||
if matches!(
|
||
self.tool_state.active_tool,
|
||
hcie_engine_api::Tool::VectorArrow
|
||
) {
|
||
let drag_angle = (y1 - y0).atan2(x1 - x0);
|
||
let drag_angle = if self.modifiers.shift() {
|
||
crate::vector_edit::snap_to_15deg(drag_angle)
|
||
} else {
|
||
drag_angle
|
||
};
|
||
shape.set_angle(drag_angle);
|
||
} else if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
|
||
shape.set_angle(svg_angle);
|
||
}
|
||
engine.add_vector_shape(shape);
|
||
self.documents[self.active_doc].modified = true;
|
||
// Reset angle so every new drawing starts at 0°.
|
||
self.settings.tool_settings.vector_angle = 0.0;
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
|
||
// ── Vector select / edit ─────────────────────
|
||
Message::VectorSelectClick { x, y } => {
|
||
use hcie_engine_api::VectorEditHandle as VEH;
|
||
|
||
// If a shape is already selected, hit-test its edit handles first.
|
||
if self.documents[self.active_doc]
|
||
.selected_vector_shape
|
||
.is_some()
|
||
{
|
||
let handle = self.vector_hit_test_handle(x, y);
|
||
if handle != VEH::None && handle != VEH::Move {
|
||
// Clicked on a resize/rotate handle — start drag with that handle.
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let layer_id = doc.engine.active_layer_id();
|
||
let shape_index = doc.selected_vector_shape.unwrap();
|
||
if let Some(original) = doc
|
||
.engine
|
||
.active_vector_shapes()
|
||
.and_then(|shapes| shapes.get(shape_index).cloned())
|
||
{
|
||
doc.vector_edit_handle = handle;
|
||
self.vector_drag_last = Some((x, y));
|
||
self.vector_drag_session =
|
||
Some(crate::vector_edit::VectorDragSession {
|
||
layer_id,
|
||
shape_index,
|
||
handle,
|
||
pointer_start: (x, y),
|
||
original,
|
||
});
|
||
}
|
||
return Task::none();
|
||
}
|
||
}
|
||
|
||
// Find ALL shapes at the click position for cycling support.
|
||
// Collect indices of shapes whose bounding box contains (x, y),
|
||
// iterating forward so index 0 is the bottom-most shape.
|
||
if let Some(shapes) = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
{
|
||
let hits: Vec<usize> = shapes
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, s)| {
|
||
let (l, t, r, b) = s.normalized_bounds();
|
||
x >= l && x <= r && y >= t && y <= b
|
||
})
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
|
||
if !hits.is_empty() {
|
||
// Save snapshot before editing (for undo).
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if self.vector_shapes_snapshot.as_ref().map(|s| s.layer_id)
|
||
!= Some(layer_id)
|
||
{
|
||
self.vector_shapes_snapshot =
|
||
Some(crate::vector_edit::VectorEditSnapshot {
|
||
layer_id,
|
||
shapes: shapes.clone(),
|
||
});
|
||
}
|
||
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let currently_selected = doc.selected_vector_shape;
|
||
|
||
doc.selected_vector_shape =
|
||
crate::vector_edit::cycle_selection(&hits, currently_selected);
|
||
doc.vector_cycle_index = doc
|
||
.selected_vector_shape
|
||
.and_then(|selected| hits.iter().position(|hit| *hit == selected))
|
||
.unwrap_or(0);
|
||
|
||
doc.vector_draw = None; // clear any preview
|
||
doc.vector_edit_handle = VEH::Move;
|
||
self.vector_drag_last = Some((x, y));
|
||
if let Some(shape_index) = doc.selected_vector_shape {
|
||
self.vector_drag_session =
|
||
Some(crate::vector_edit::VectorDragSession {
|
||
layer_id,
|
||
shape_index,
|
||
handle: VEH::Move,
|
||
pointer_start: (x, y),
|
||
original: shapes[shape_index].clone(),
|
||
});
|
||
}
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
} else {
|
||
// No shape under cursor — deselect.
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.selected_vector_shape = None;
|
||
doc.vector_cycle_index = 0;
|
||
doc.vector_edit_handle = VEH::None;
|
||
doc.vector_drag_preview = None;
|
||
self.vector_shapes_snapshot = None;
|
||
self.vector_drag_last = None;
|
||
self.vector_drag_session = None;
|
||
}
|
||
} else {
|
||
// No vector shapes on this layer — deselect.
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.selected_vector_shape = None;
|
||
doc.vector_cycle_index = 0;
|
||
doc.vector_edit_handle = VEH::None;
|
||
doc.vector_drag_preview = None;
|
||
self.vector_shapes_snapshot = None;
|
||
self.vector_drag_last = None;
|
||
self.vector_drag_session = None;
|
||
}
|
||
}
|
||
Message::VectorSelectDragStart { x, y } => {
|
||
if self.documents[self.active_doc]
|
||
.selected_vector_shape
|
||
.is_some()
|
||
{
|
||
// Hit-test vector edit handles to determine drag mode.
|
||
let handle = self.vector_hit_test_handle(x, y);
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let layer_id = doc.engine.active_layer_id();
|
||
let shape_index = doc.selected_vector_shape.unwrap();
|
||
if let Some(original) = doc
|
||
.engine
|
||
.active_vector_shapes()
|
||
.and_then(|shapes| shapes.get(shape_index).cloned())
|
||
{
|
||
doc.vector_edit_handle = handle;
|
||
self.vector_drag_last = Some((x, y));
|
||
self.vector_drag_session = Some(crate::vector_edit::VectorDragSession {
|
||
layer_id,
|
||
shape_index,
|
||
handle,
|
||
pointer_start: (x, y),
|
||
original,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
Message::VectorSelectDragMove { x, y } => {
|
||
if let Some(session) = self.vector_drag_session.clone() {
|
||
let active_layer = self.documents[self.active_doc].engine.active_layer_id();
|
||
if active_layer != session.layer_id
|
||
|| self.documents[self.active_doc].selected_vector_shape
|
||
!= Some(session.shape_index)
|
||
{
|
||
self.vector_drag_last = None;
|
||
self.vector_drag_session = None;
|
||
self.documents[self.active_doc].vector_drag_preview = None;
|
||
return Task::none();
|
||
}
|
||
|
||
let shape = crate::vector_edit::transform_shape(
|
||
&session,
|
||
(x, y),
|
||
self.modifiers.shift(),
|
||
self.modifiers.alt(),
|
||
self.modifiers.shift(),
|
||
);
|
||
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
||
let angle = shape.angle();
|
||
|
||
// Keep the transformed shape in a lightweight overlay preview
|
||
// instead of mutating the engine on every pointer event. The
|
||
// expensive vector rasterization and full-layer composite only
|
||
// happen once when the drag ends.
|
||
self.documents[self.active_doc].vector_drag_preview = Some(shape);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.vector_drag_last = Some((x, y));
|
||
self.vector_drag_last_bounds = Some((x1, y1, x2, y2));
|
||
self.vector_drag_last_angle = Some(angle);
|
||
self.settings.tool_settings.vector_angle = angle.to_degrees();
|
||
|
||
// Track snap state for visual overlay feedback
|
||
let is_rotate =
|
||
matches!(session.handle, hcie_engine_api::VectorEditHandle::Rotate);
|
||
let is_resize = matches!(
|
||
session.handle,
|
||
hcie_engine_api::VectorEditHandle::TopLeft
|
||
| hcie_engine_api::VectorEditHandle::TopRight
|
||
| hcie_engine_api::VectorEditHandle::BottomLeft
|
||
| hcie_engine_api::VectorEditHandle::BottomRight
|
||
);
|
||
self.documents[self.active_doc].rotation_snap_active =
|
||
is_rotate && self.modifiers.shift();
|
||
self.documents[self.active_doc].rotation_snap_angle = angle;
|
||
self.documents[self.active_doc].resize_snap_active =
|
||
is_resize && self.modifiers.shift();
|
||
}
|
||
}
|
||
Message::VectorSelectDragEnd => {
|
||
if self.vector_drag_last.is_some() {
|
||
// Commit the previewed transform to the engine in one
|
||
// transaction, avoiding per-event rasterization.
|
||
if let Some(session) = self.vector_drag_session.as_ref() {
|
||
if let Some(shape) =
|
||
self.documents[self.active_doc].vector_drag_preview.take()
|
||
{
|
||
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
||
let angle = shape.angle();
|
||
let engine = &mut self.documents[self.active_doc].engine;
|
||
engine.set_vector_shape_bounds(
|
||
session.layer_id,
|
||
session.shape_index,
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
);
|
||
engine.set_vector_shape_angle(
|
||
session.layer_id,
|
||
session.shape_index,
|
||
angle,
|
||
);
|
||
}
|
||
}
|
||
// Commit the move/resize/rotate: if shapes changed, push history snapshot.
|
||
self.vector_drag_last = None;
|
||
self.vector_drag_last_bounds = None;
|
||
self.vector_drag_last_angle = None;
|
||
self.vector_drag_session = None;
|
||
self.documents[self.active_doc].vector_drag_preview = None;
|
||
self.documents[self.active_doc].vector_edit_handle =
|
||
hcie_engine_api::VectorEditHandle::None;
|
||
self.documents[self.active_doc].rotation_snap_active = false;
|
||
self.documents[self.active_doc].resize_snap_active = false;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
Message::VectorShapeDelete => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.delete_vector_shape(layer_id, idx);
|
||
self.documents[self.active_doc].selected_vector_shape = None;
|
||
self.documents[self.active_doc].modified = true;
|
||
self.documents[self.active_doc].vector_cycle_index = 0;
|
||
self.documents[self.active_doc].vector_drag_preview = None;
|
||
self.vector_shapes_snapshot = None;
|
||
self.vector_drag_session = None;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
} else {
|
||
return self.update(Message::Deselect);
|
||
}
|
||
}
|
||
Message::VectorShapeMoveUp => {
|
||
// Move the selected shape toward the front of the Z-order (higher index).
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let count = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
.map_or(0, |s| s.len());
|
||
if idx + 1 < count {
|
||
self.documents[self.active_doc].engine.reorder_vector_shape(
|
||
layer_id,
|
||
idx,
|
||
idx + 1,
|
||
);
|
||
self.documents[self.active_doc].selected_vector_shape = Some(idx + 1);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
}
|
||
Message::VectorShapeMoveDown => {
|
||
// Move the selected shape toward the back of the Z-order (lower index).
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
if idx > 0 {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc].engine.reorder_vector_shape(
|
||
layer_id,
|
||
idx,
|
||
idx - 1,
|
||
);
|
||
self.documents[self.active_doc].selected_vector_shape = Some(idx - 1);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
}
|
||
Message::VectorShapeRename(idx, new_name) => {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if let Some(shapes) = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_shapes_direct(layer_id)
|
||
{
|
||
if let Some(shape) = shapes.get_mut(idx) {
|
||
shape.set_name(new_name);
|
||
self.documents[self.active_doc].modified = true;
|
||
}
|
||
}
|
||
}
|
||
Message::VectorShapeSelect(idx) => {
|
||
self.documents[self.active_doc].selected_vector_shape = idx;
|
||
if idx.is_some() {
|
||
self.documents[self.active_doc].vector_cycle_index = 0;
|
||
let doc = &self.documents[self.active_doc];
|
||
if let Some(shapes) = doc.engine.active_vector_shapes() {
|
||
self.vector_shapes_snapshot =
|
||
Some(crate::vector_edit::VectorEditSnapshot {
|
||
layer_id: doc.engine.active_layer_id(),
|
||
shapes,
|
||
});
|
||
}
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
} else {
|
||
self.documents[self.active_doc].vector_cycle_index = 0;
|
||
self.vector_shapes_snapshot = None;
|
||
}
|
||
self.vector_drag_session = None;
|
||
self.documents[self.active_doc].vector_drag_preview = None;
|
||
}
|
||
// ── Vector shape apply / cancel / flip ──────
|
||
Message::VectorShapeApply => {
|
||
// Commit any pending drag preview to the engine before deselecting,
|
||
// then clear the snapshot so Cancel cannot revert.
|
||
let need_refresh = {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
if let Some(shape) = doc.vector_drag_preview.take() {
|
||
let shape_idx = doc.selected_vector_shape;
|
||
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
||
let angle = shape.angle();
|
||
let layer_id = doc.engine.active_layer_id();
|
||
if let Some(idx) = shape_idx {
|
||
doc.engine
|
||
.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, y2);
|
||
doc.engine.set_vector_shape_angle(layer_id, idx, angle);
|
||
}
|
||
doc.engine.mark_composite_dirty();
|
||
doc.engine.is_composite_dirty()
|
||
} else {
|
||
false
|
||
}
|
||
};
|
||
if need_refresh {
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
self.documents[self.active_doc].selected_vector_shape = None;
|
||
self.documents[self.active_doc].vector_cycle_index = 0;
|
||
self.documents[self.active_doc].vector_edit_handle =
|
||
hcie_engine_api::VectorEditHandle::None;
|
||
self.vector_shapes_snapshot = None;
|
||
self.vector_drag_last = None;
|
||
self.vector_drag_session = None;
|
||
}
|
||
Message::VectorShapeCancel => {
|
||
// Restore snapshot: put original shapes back, then deselect.
|
||
if let Some(snapshot) = self.vector_shapes_snapshot.take() {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
if let Some(shapes) = doc.engine.get_layer_shapes_direct(snapshot.layer_id) {
|
||
*shapes = snapshot.shapes;
|
||
}
|
||
doc.selected_vector_shape = None;
|
||
doc.vector_cycle_index = 0;
|
||
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
|
||
doc.vector_drag_preview = None;
|
||
self.vector_drag_last = None;
|
||
self.vector_drag_session = None;
|
||
doc.engine.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
Message::VectorShapeFlipH => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if let Some(shapes) = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_shapes_direct(layer_id)
|
||
{
|
||
if let Some(shape) = shapes.get_mut(idx) {
|
||
match shape {
|
||
hcie_engine_api::VectorShape::FreePath { pts, .. } => {
|
||
if pts.is_empty() {
|
||
return Task::none();
|
||
}
|
||
let cx =
|
||
pts.iter().map(|p| p[0]).sum::<f32>() / pts.len() as f32;
|
||
for p in pts.iter_mut() {
|
||
p[0] = 2.0 * cx - p[0];
|
||
}
|
||
}
|
||
_ => {
|
||
let (x1, y1, x2, y2) = shape.bounds();
|
||
// Swap x1/x2 to flip horizontally.
|
||
shape.set_bounds(x2, y1, x1, y2);
|
||
}
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Message::VectorShapeFlipV => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if let Some(shapes) = self.documents[self.active_doc]
|
||
.engine
|
||
.get_layer_shapes_direct(layer_id)
|
||
{
|
||
if let Some(shape) = shapes.get_mut(idx) {
|
||
match shape {
|
||
hcie_engine_api::VectorShape::FreePath { pts, .. } => {
|
||
if pts.is_empty() {
|
||
return Task::none();
|
||
}
|
||
let cy =
|
||
pts.iter().map(|p| p[1]).sum::<f32>() / pts.len() as f32;
|
||
for p in pts.iter_mut() {
|
||
p[1] = 2.0 * cy - p[1];
|
||
}
|
||
}
|
||
_ => {
|
||
let (x1, y1, x2, y2) = shape.bounds();
|
||
// Swap y1/y2 to flip vertically.
|
||
shape.set_bounds(x1, y2, x2, y1);
|
||
}
|
||
}
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Geometry panel controls ───────────────────
|
||
Message::GeometryBoundsX1(v) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let shapes = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
.unwrap_or_default();
|
||
if let Some(s) = shapes.get(idx) {
|
||
let (_, y1, x2, y2) = s.bounds();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_bounds(layer_id, idx, v, y1, x2, y2);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
Message::GeometryBoundsY1(v) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let shapes = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
.unwrap_or_default();
|
||
if let Some(s) = shapes.get(idx) {
|
||
let (x1, _, x2, y2) = s.bounds();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_bounds(layer_id, idx, x1, v, x2, y2);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
Message::GeometryBoundsX2(v) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let shapes = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
.unwrap_or_default();
|
||
if let Some(s) = shapes.get(idx) {
|
||
let (x1, y1, _, y2) = s.bounds();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_bounds(layer_id, idx, x1, y1, v, y2);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
Message::GeometryBoundsY2(v) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let shapes = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
.unwrap_or_default();
|
||
if let Some(s) = shapes.get(idx) {
|
||
let (x1, y1, x2, _) = s.bounds();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, v);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
Message::GeometryAngleChanged(angle_deg) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_angle(layer_id, idx, angle_deg.to_radians());
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
Message::GeometryFillToggled(v) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_fill(layer_id, idx, v);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
Message::GeometryFillColorChanged(c) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_fill_color(layer_id, idx, c);
|
||
self.documents[self.active_doc].modified = true;
|
||
if !self.color_dragging {
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
Message::GeometryStrokeColorChanged(c) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_color(layer_id, idx, c);
|
||
self.documents[self.active_doc].modified = true;
|
||
if !self.color_dragging {
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
Message::GeometryOpacityChanged(v) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_opacity(layer_id, idx, v);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
Message::GeometryHardnessChanged(v) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_hardness(layer_id, idx, v);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
Message::GeometryBoolShapeA(idx) => {
|
||
self.bool_shape_a = idx;
|
||
}
|
||
Message::GeometryBoolShapeB(idx) => {
|
||
self.bool_shape_b = idx;
|
||
}
|
||
Message::GeometryBooleanOp(op) => {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
if let (Some(a), Some(b)) = (self.bool_shape_a, self.bool_shape_b) {
|
||
let shape_count = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
.map_or(0, |shapes| shapes.len());
|
||
let valid = a < shape_count && b < shape_count && a != b;
|
||
let succeeded = valid
|
||
&& self.documents[self.active_doc]
|
||
.engine
|
||
.boolean_vector_shapes(layer_id, op, a, b);
|
||
if succeeded {
|
||
let result_index = shape_count - 2;
|
||
self.documents[self.active_doc].selected_vector_shape = Some(result_index);
|
||
self.documents[self.active_doc].vector_cycle_index = 0;
|
||
self.bool_shape_a = None;
|
||
self.bool_shape_b = None;
|
||
self.vector_shapes_snapshot = None;
|
||
self.documents[self.active_doc].modified = true;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
Message::GeometryLineCapStart(cap) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let shapes = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
.unwrap_or_default();
|
||
if let Some(s) = shapes.get(idx) {
|
||
let ce = match s {
|
||
hcie_engine_api::VectorShape::Line { cap_end, .. } => *cap_end,
|
||
_ => hcie_engine_api::LineCap::Round,
|
||
};
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_line_caps(layer_id, idx, cap, ce);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
Message::GeometryLineCapEnd(cap) => {
|
||
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
|
||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
let shapes = self.documents[self.active_doc]
|
||
.engine
|
||
.active_vector_shapes()
|
||
.unwrap_or_default();
|
||
if let Some(s) = shapes.get(idx) {
|
||
let cs = match s {
|
||
hcie_engine_api::VectorShape::Line { cap_start, .. } => *cap_start,
|
||
_ => hcie_engine_api::LineCap::Round,
|
||
};
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.set_vector_shape_line_caps(layer_id, idx, cs, cap);
|
||
self.documents[self.active_doc].modified = true;
|
||
crate::shape_sync::sync_tool_from_shape(self);
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.mark_composite_dirty();
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Lasso / polygon selection ─────────────────
|
||
Message::LassoDragMove(x, y) => {
|
||
let cx = x.round().max(0.0) as u32;
|
||
let cy = y.round().max(0.0) as u32;
|
||
let pts = &mut self.documents[self.active_doc].lasso_points;
|
||
// Throttle duplicate points to keep the polygon light.
|
||
if pts.last().map_or(true, |l| l.0 != cx || l.1 != cy) {
|
||
pts.push((cx, cy));
|
||
}
|
||
}
|
||
Message::LassoDragEnd => {
|
||
let pts = std::mem::take(&mut self.documents[self.active_doc].lasso_points);
|
||
if pts.len() >= 3 {
|
||
let previous = self.documents[self.active_doc]
|
||
.engine
|
||
.get_selection_mask()
|
||
.map(ToOwned::to_owned);
|
||
let pts_ref: Vec<(u32, u32)> = pts;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.create_selection_lasso(&pts_ref);
|
||
self.combine_current_selection(previous);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
Message::PolygonAddPoint(x, y) => {
|
||
let cx = x.round().max(0.0) as u32;
|
||
let cy = y.round().max(0.0) as u32;
|
||
self.documents[self.active_doc]
|
||
.polygon_points
|
||
.push((cx, cy));
|
||
}
|
||
Message::PolygonClose => {
|
||
let pts = std::mem::take(&mut self.documents[self.active_doc].polygon_points);
|
||
if pts.len() >= 3 {
|
||
let previous = self.documents[self.active_doc]
|
||
.engine
|
||
.get_selection_mask()
|
||
.map(ToOwned::to_owned);
|
||
let pts_ref: Vec<(u32, u32)> = pts;
|
||
self.documents[self.active_doc]
|
||
.engine
|
||
.create_selection_polygon(&pts_ref);
|
||
self.combine_current_selection(previous);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
|
||
// ── Gradient tool ────────────────────────────
|
||
Message::GradientDragStart(x, y) => {
|
||
self.documents[self.active_doc].gradient_drag = Some(((x, y), (x, y)));
|
||
self.tool_state.is_drawing = true;
|
||
}
|
||
Message::GradientDragMove(x, y) => {
|
||
if let Some((start, _)) = self.documents[self.active_doc].gradient_drag {
|
||
self.documents[self.active_doc].gradient_drag = Some((start, (x, y)));
|
||
}
|
||
// Also drive the VisionSelect bbox preview.
|
||
if self.tool_state.active_tool == Tool::VisionSelect {
|
||
if let Some((x0, y0, _, _)) = self.documents[self.active_doc].vision_rect {
|
||
self.documents[self.active_doc].vision_rect = Some((x0, y0, x, y));
|
||
}
|
||
}
|
||
}
|
||
Message::GradientDragEnd => {
|
||
if let Some(((x0, y0), (x1, y1))) =
|
||
self.documents[self.active_doc].gradient_drag.take()
|
||
{
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let width = doc.engine.canvas_width();
|
||
let height = doc.engine.canvas_height();
|
||
let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||
if let Some(mut pixels) = doc.engine.get_active_layer_pixels() {
|
||
crate::raster::apply_gradient(
|
||
&mut pixels,
|
||
width,
|
||
height,
|
||
(x0, y0),
|
||
(x1, y1),
|
||
self.settings.tool_settings.gradient_type == 1,
|
||
self.fg_color,
|
||
self.bg_color,
|
||
self.settings.tool_settings.gradient_opacity,
|
||
mask.as_deref(),
|
||
);
|
||
doc.engine.set_active_layer_pixels(pixels);
|
||
doc.modified = true;
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
|
||
// ── Crop tool ────────────────────────────────
|
||
Message::CropDragStart(x, y) => {
|
||
let width = self.documents[self.active_doc].engine.canvas_width();
|
||
let height = self.documents[self.active_doc].engine.canvas_height();
|
||
self.documents[self.active_doc]
|
||
.crop_state
|
||
.start_drag_clamped(x, y, width, height);
|
||
}
|
||
Message::CropDragMove(x, y) => {
|
||
self.documents[self.active_doc].crop_state.update_drag(x, y);
|
||
}
|
||
Message::CropDragEnd => {
|
||
self.documents[self.active_doc].crop_state.end_drag();
|
||
}
|
||
Message::CropConfirm => {
|
||
if let Some((x, y, w, h)) = self.documents[self.active_doc].crop_state.confirm() {
|
||
self.documents[self.active_doc].engine.crop(x, y, w, h);
|
||
self.documents[self.active_doc].engine.selection_clear();
|
||
set_document_selection_mask(&mut self.documents[self.active_doc], None);
|
||
self.documents[self.active_doc].crop_state.cancel();
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
Message::CropCancel => {
|
||
self.documents[self.active_doc].crop_state.cancel();
|
||
}
|
||
|
||
// ── Script Editor ──────────────────────────────
|
||
Message::ScriptEditorAction(action) => {
|
||
self.script_content.perform(action);
|
||
self.script_text = self.script_content.text();
|
||
}
|
||
Message::ScriptTextChanged(text) => {
|
||
self.script_text = text;
|
||
}
|
||
Message::ScriptRun => {
|
||
match crate::script::parser::parse_dsl_script(&self.script_text) {
|
||
Ok(actions) => {
|
||
self.script_error = None;
|
||
self.script_error_line = None;
|
||
self.script_is_running = true;
|
||
crate::script::executor::execute_actions(
|
||
&mut self.documents[self.active_doc].engine,
|
||
&actions,
|
||
);
|
||
self.script_is_running = false;
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Err(e) => {
|
||
self.script_error_line = crate::script::parser::parse_error_line(&e);
|
||
self.script_error = Some(e);
|
||
}
|
||
}
|
||
}
|
||
Message::ScriptClear => {
|
||
self.script_text.clear();
|
||
self.script_error = None;
|
||
self.script_error_line = None;
|
||
}
|
||
|
||
// ── AI Chat ───────────────────────────────────
|
||
Message::AiChatInput(text) => {
|
||
self.ai_chat_state.input = text;
|
||
}
|
||
Message::AiChatSend => {
|
||
let user_msg = self.ai_chat_state.input.trim().to_string();
|
||
if user_msg.is_empty() || self.ai_chat_state.is_streaming {
|
||
return Task::none();
|
||
}
|
||
|
||
// Add user message to history
|
||
self.ai_chat_state.messages.push(ChatMessage {
|
||
role: "user".to_string(),
|
||
content: user_msg.clone(),
|
||
is_reasoning: false,
|
||
});
|
||
self.ai_chat_state.input.clear();
|
||
self.ai_chat_state.is_streaming = true;
|
||
self.ai_chat_state.current_response.clear();
|
||
self.ai_chat_state.current_reasoning.clear();
|
||
|
||
// Build the final prompt with optional canvas context
|
||
let mut final_prompt = user_msg.clone();
|
||
if self.ai_chat_state.config.canvas_context {
|
||
let doc = &self.documents[self.active_doc];
|
||
let w = doc.engine.canvas_width();
|
||
let h = doc.engine.canvas_height();
|
||
let layers = doc.engine.layer_infos();
|
||
let mut meta =
|
||
format!("\n\n[Active Canvas State: Size {}x{} px. Layers:", w, h);
|
||
for l in &layers {
|
||
meta.push_str(&format!(
|
||
"\n- \"{}\" (Type: {:?}, ID: {}, Visible: {}, Opacity: {:.0}%)",
|
||
l.name,
|
||
l.layer_type,
|
||
l.id,
|
||
l.visible,
|
||
l.opacity * 100.0
|
||
));
|
||
}
|
||
meta.push_str("]");
|
||
final_prompt.push_str(&meta);
|
||
}
|
||
|
||
// Build messages JSON for API
|
||
let messages_json = ai_chat::build_messages_json(
|
||
&self.ai_chat_state.messages,
|
||
&final_prompt,
|
||
SYSTEM_PROMPT,
|
||
);
|
||
|
||
// Clone config for the async task
|
||
let url = self.ai_chat_state.config.base_url.clone();
|
||
let model = self.ai_chat_state.config.model.clone();
|
||
|
||
let provider = self.ai_chat_state.config.provider.clone();
|
||
let task = Task::run(
|
||
ai_chat::stream_llm_response(provider, url, model, messages_json),
|
||
|update| match update {
|
||
ai_chat::ChatUpdate::Chunk(chunk) => Message::AiChatStreamChunk(chunk),
|
||
ai_chat::ChatUpdate::ReasoningChunk(chunk) => {
|
||
Message::AiChatReasoningChunk(chunk)
|
||
}
|
||
ai_chat::ChatUpdate::Finished(result) => {
|
||
Message::AiChatStreamFinished(result)
|
||
}
|
||
},
|
||
);
|
||
let (task, handle) = task.abortable();
|
||
self.ai_chat_abort = Some(handle);
|
||
return task;
|
||
}
|
||
Message::AiChatClear => {
|
||
self.ai_chat_state.messages.clear();
|
||
self.ai_chat_state.current_response.clear();
|
||
self.ai_chat_state.current_reasoning.clear();
|
||
self.ai_chat_state.is_streaming = false;
|
||
if let Some(handle) = self.ai_chat_abort.take() {
|
||
handle.abort();
|
||
}
|
||
}
|
||
Message::AiChatProviderChanged(provider) => match provider.as_str() {
|
||
"Ollama" => {
|
||
self.ai_chat_state.config.provider = ai_chat::AiProvider::Ollama;
|
||
self.ai_chat_state.config.base_url = "http://localhost:11434".to_string();
|
||
}
|
||
"Claude" => {
|
||
self.ai_chat_state.config.provider = ai_chat::AiProvider::Claude;
|
||
self.ai_chat_state.config.base_url = "https://api.anthropic.com".to_string();
|
||
}
|
||
"OpenAI" => {
|
||
self.ai_chat_state.config.provider = ai_chat::AiProvider::OpenAI;
|
||
self.ai_chat_state.config.base_url = "https://api.openai.com/v1".to_string();
|
||
}
|
||
_ => {}
|
||
},
|
||
Message::AiChatUrlChanged(url) => {
|
||
self.ai_chat_state.config.base_url = url;
|
||
}
|
||
Message::AiChatModelChanged(model) => {
|
||
self.ai_chat_state.config.model = model;
|
||
}
|
||
Message::AiChatReasoningToggled(enabled) => {
|
||
self.ai_chat_state.config.reasoning_enabled = enabled;
|
||
}
|
||
Message::AiChatCanvasContextToggled(enabled) => {
|
||
self.ai_chat_state.config.canvas_context = enabled;
|
||
}
|
||
Message::AiChatStreamChunk(chunk) => {
|
||
self.ai_chat_state.current_response.push_str(&chunk);
|
||
}
|
||
Message::AiChatReasoningChunk(chunk) => {
|
||
self.ai_chat_state.current_reasoning.push_str(&chunk);
|
||
}
|
||
Message::AiChatStreamFinished(result) => {
|
||
self.ai_chat_abort = None;
|
||
self.ai_chat_state.is_streaming = false;
|
||
match result {
|
||
Ok(full_response) => {
|
||
// Execute any embedded tool calls
|
||
let tool_results = ai_chat::execute_inline_tools(
|
||
&full_response,
|
||
&mut self.documents[self.active_doc].engine,
|
||
);
|
||
for res in &tool_results {
|
||
self.ai_chat_state.messages.push(ChatMessage {
|
||
role: "tool".to_string(),
|
||
content: res.clone(),
|
||
is_reasoning: false,
|
||
});
|
||
}
|
||
|
||
// Execute any DSL code blocks
|
||
if let Some(dsl_code) = ai_chat::extract_dsl_block(&full_response) {
|
||
let commands = ai_chat::parse_dsl_script(
|
||
&dsl_code,
|
||
&self.documents[self.active_doc].engine,
|
||
);
|
||
for cmd in &commands {
|
||
ai_chat::execute_command_on_engine(
|
||
&mut self.documents[self.active_doc].engine,
|
||
cmd,
|
||
);
|
||
}
|
||
self.ai_chat_state.messages.push(ChatMessage {
|
||
role: "tool".to_string(),
|
||
content: "▶ Automatically executed AI procedural drawing script."
|
||
.to_string(),
|
||
is_reasoning: false,
|
||
});
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
|
||
// Add the full assistant response to history
|
||
self.ai_chat_state.messages.push(ChatMessage {
|
||
role: "assistant".to_string(),
|
||
content: full_response,
|
||
is_reasoning: false,
|
||
});
|
||
if !self.ai_chat_state.current_reasoning.is_empty() {
|
||
self.ai_chat_state.messages.push(ChatMessage {
|
||
role: "assistant".to_string(),
|
||
content: std::mem::take(&mut self.ai_chat_state.current_reasoning),
|
||
is_reasoning: true,
|
||
});
|
||
}
|
||
self.ai_chat_state.current_response.clear();
|
||
}
|
||
Err(e) => {
|
||
self.ai_chat_state.messages.push(ChatMessage {
|
||
role: "assistant".to_string(),
|
||
content: format!("❌ Connection Error: {}", e),
|
||
is_reasoning: false,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
Message::AiChatCancel => {
|
||
if let Some(handle) = self.ai_chat_abort.take() {
|
||
handle.abort();
|
||
}
|
||
if !self.ai_chat_state.current_response.is_empty() {
|
||
self.ai_chat_state.messages.push(ChatMessage {
|
||
role: "assistant".to_string(),
|
||
content: std::mem::take(&mut self.ai_chat_state.current_response),
|
||
is_reasoning: false,
|
||
});
|
||
}
|
||
self.ai_chat_state.is_streaming = false;
|
||
}
|
||
Message::AiChatCopyTranscript => {
|
||
let mut messages = self.ai_chat_state.messages.clone();
|
||
if !self.ai_chat_state.current_response.is_empty() {
|
||
messages.push(ChatMessage {
|
||
role: "assistant".to_string(),
|
||
content: self.ai_chat_state.current_response.clone(),
|
||
is_reasoning: false,
|
||
});
|
||
}
|
||
if !self.ai_chat_state.current_reasoning.is_empty() {
|
||
messages.push(ChatMessage {
|
||
role: "assistant".to_string(),
|
||
content: self.ai_chat_state.current_reasoning.clone(),
|
||
is_reasoning: true,
|
||
});
|
||
}
|
||
return iced::clipboard::write(ai_chat::transcript_text(&messages));
|
||
}
|
||
Message::AiAssistantTabChanged(tab) => {
|
||
self.ai_assistant_tab = tab;
|
||
}
|
||
Message::ComfyUiCheckStatus => {
|
||
// Placeholder: check ComfyUI server health.
|
||
// Full implementation would use hcie-vision to query the ComfyUI API.
|
||
self.comfy_state.status = ai_chat::ComfyStatus::Running;
|
||
}
|
||
Message::ComfyUiUrlChanged(url) => {
|
||
self.comfy_state.url = url;
|
||
}
|
||
|
||
// ── AI Script Generator ───────────────────────
|
||
Message::AiScriptPromptChanged(text) => {
|
||
self.ai_script_prompt = text;
|
||
}
|
||
Message::AiScriptProviderChanged(provider) => {
|
||
self.ai_script_provider = provider.clone();
|
||
// Set sensible defaults when switching providers
|
||
match provider.as_str() {
|
||
"ollama" => {
|
||
self.ai_script_url = "http://localhost:11434".to_string();
|
||
self.ai_script_model = "qwen2.5:7b".to_string();
|
||
}
|
||
"claude" => {
|
||
self.ai_script_url = "https://api.anthropic.com".to_string();
|
||
self.ai_script_model = "claude-sonnet-4-20250514".to_string();
|
||
}
|
||
"openai" => {
|
||
self.ai_script_url = "https://api.openai.com/v1".to_string();
|
||
self.ai_script_model = "gpt-4o".to_string();
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
Message::AiScriptModelChanged(model) => {
|
||
self.ai_script_model = model;
|
||
}
|
||
Message::AiScriptUrlChanged(url) => {
|
||
self.ai_script_url = url;
|
||
}
|
||
Message::AiScriptGenerate => {
|
||
if self.ai_script_prompt.trim().is_empty() || self.ai_script_is_running {
|
||
return Task::none();
|
||
}
|
||
self.ai_script_is_running = true;
|
||
self.ai_script_error = None;
|
||
self.ai_script_notice = None;
|
||
self.ai_script_output.clear();
|
||
|
||
let prompt = self.ai_script_prompt.clone();
|
||
let provider_type = self.ai_script_provider.clone();
|
||
let provider = crate::ai_script::Provider {
|
||
base_url: self.ai_script_url.clone(),
|
||
model: self.ai_script_model.clone(),
|
||
};
|
||
|
||
return Task::perform(
|
||
async move {
|
||
crate::ai_script::generate_script(&provider_type, &provider, &prompt).await
|
||
},
|
||
Message::AiScriptResult,
|
||
);
|
||
}
|
||
Message::AiScriptRun => {
|
||
if self.ai_script_output.is_empty() {
|
||
return Task::none();
|
||
}
|
||
match crate::script::parser::parse_dsl_script(&self.ai_script_output) {
|
||
Ok(actions) => {
|
||
self.ai_script_error = None;
|
||
self.ai_script_notice = Some(format!(
|
||
"Executed {} validated script actions.",
|
||
actions.len()
|
||
));
|
||
crate::script::executor::execute_actions(
|
||
&mut self.documents[self.active_doc].engine,
|
||
&actions,
|
||
);
|
||
self.documents[self.active_doc].modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Err(e) => {
|
||
self.ai_script_error = Some(e);
|
||
}
|
||
}
|
||
}
|
||
Message::AiScriptCopy => {
|
||
if !self.ai_script_output.is_empty() {
|
||
// Copy script to the script editor panel
|
||
self.script_text = self.ai_script_output.clone();
|
||
self.script_content =
|
||
iced::widget::text_editor::Content::with_text(&self.ai_script_output);
|
||
self.ai_script_notice =
|
||
Some("Copied generated script to the Script panel.".to_string());
|
||
}
|
||
}
|
||
Message::AiScriptCopyOutput => {
|
||
if !self.ai_script_output.is_empty() {
|
||
self.ai_script_notice =
|
||
Some("Copied generated script to the system clipboard.".to_string());
|
||
return iced::clipboard::write(self.ai_script_output.clone());
|
||
}
|
||
}
|
||
Message::AiScriptClear => {
|
||
self.ai_script_prompt.clear();
|
||
self.ai_script_output.clear();
|
||
self.ai_script_error = None;
|
||
self.ai_script_notice = None;
|
||
self.ai_script_is_running = false;
|
||
}
|
||
Message::AiScriptResult(result) => {
|
||
self.ai_script_is_running = false;
|
||
match result {
|
||
Ok(script) => {
|
||
self.ai_script_output = script;
|
||
self.ai_script_notice =
|
||
Some("Generated script validated and is ready to run.".to_string());
|
||
}
|
||
Err(e) => {
|
||
self.ai_script_error = Some(e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Clipboard ─────────────────────────────────
|
||
Message::CopyImage => {
|
||
let (pixels, w, h) = {
|
||
let doc = &self.documents[self.active_doc];
|
||
let canvas_w = doc.engine.canvas_width();
|
||
let canvas_h = doc.engine.canvas_height();
|
||
if let Some(mask) = doc.engine.get_selection_mask() {
|
||
if let Some(transform) = selection::clipboard::extract_masked_pixels(
|
||
&doc.composite_raw,
|
||
mask,
|
||
canvas_w,
|
||
canvas_h,
|
||
) {
|
||
let result =
|
||
(transform.pixels.clone(), transform.width, transform.height);
|
||
self.documents[self.active_doc].internal_clipboard = Some(transform);
|
||
result
|
||
} else {
|
||
(doc.composite_raw.clone(), canvas_w, canvas_h)
|
||
}
|
||
} else {
|
||
(doc.composite_raw.clone(), canvas_w, canvas_h)
|
||
}
|
||
};
|
||
return Task::perform(
|
||
async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) },
|
||
Message::ImageCopied,
|
||
);
|
||
}
|
||
Message::CutImage => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let w = doc.engine.canvas_width();
|
||
let h = doc.engine.canvas_height();
|
||
let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||
if let Some(mask) = mask {
|
||
if let Some(transform) =
|
||
selection::clipboard::extract_masked_pixels(&doc.composite_raw, &mask, w, h)
|
||
{
|
||
let _ = crate::io::clipboard::copy_image_to_clipboard(
|
||
&transform.pixels,
|
||
transform.width,
|
||
transform.height,
|
||
);
|
||
doc.internal_clipboard = Some(transform);
|
||
// Record an undoable snapshot of the source pixels before
|
||
// clearing them, then clear the selected region via the engine.
|
||
doc.engine.clear_selection_pixels();
|
||
doc.modified = true;
|
||
}
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
Message::PasteImage => {
|
||
// Check internal clipboard first
|
||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
|
||
{
|
||
// Enter transform mode with the clipboard content
|
||
let mut placed = transform;
|
||
placed.pos.x += 10.0;
|
||
placed.pos.y += 10.0;
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
|
||
doc.transform_selection_baseline =
|
||
doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||
doc.transform_source = None;
|
||
doc.transform_preview_base = Some(doc.composite_raw.clone());
|
||
doc.selection_transform = Some(placed);
|
||
doc.transform_drag_handle = TransformHandle::None;
|
||
render_transform_preview(doc, None);
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
|
||
// Fall back to system clipboard
|
||
return Task::perform(
|
||
async { crate::io::clipboard::paste_image_from_clipboard() },
|
||
Message::ImagePasted,
|
||
);
|
||
}
|
||
Message::PasteAsNewLayer => {
|
||
// Check internal clipboard first
|
||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
|
||
{
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let canvas_w = doc.engine.canvas_width();
|
||
let canvas_h = doc.engine.canvas_height();
|
||
let centered = selection::clipboard::centered_layer_pixels(
|
||
&transform.pixels,
|
||
transform.width,
|
||
transform.height,
|
||
canvas_w,
|
||
canvas_h,
|
||
);
|
||
let id = doc.engine.add_layer("Pasted Layer");
|
||
doc.engine.set_layer_pixels(id, centered);
|
||
doc.engine.set_active_layer(id);
|
||
doc.modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
// Fall back to system clipboard
|
||
return Task::perform(
|
||
async { crate::io::clipboard::paste_image_from_clipboard() },
|
||
Message::ImagePastedAsNewLayer,
|
||
);
|
||
}
|
||
Message::CopyText(text) => {
|
||
let text = text.clone();
|
||
return Task::perform(
|
||
async move { crate::io::clipboard::copy_text_to_clipboard(&text) },
|
||
|_| Message::NoOp,
|
||
);
|
||
}
|
||
Message::PasteText => {
|
||
return Task::perform(
|
||
async { crate::io::clipboard::paste_text_from_clipboard() },
|
||
Message::ClipboardResult,
|
||
);
|
||
}
|
||
Message::ClipboardResult(Ok(Some(text))) => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let draft = doc.text_draft.get_or_insert_with(TextDraft::default);
|
||
draft.content.push_str(&text);
|
||
draft.editor = iced::widget::text_editor::Content::with_text(&draft.content);
|
||
}
|
||
Message::ClipboardResult(Ok(None)) => {
|
||
log::info!("Clipboard is empty");
|
||
}
|
||
Message::ClipboardResult(Err(e)) => {
|
||
log::error!("Clipboard error: {}", e);
|
||
}
|
||
Message::ImageCopied(Ok(())) => {
|
||
log::info!("Image copied to clipboard");
|
||
}
|
||
Message::ImageCopied(Err(e)) => {
|
||
log::error!("Failed to copy image: {}", e);
|
||
}
|
||
Message::ImagePasted(Ok(Some((pixels, w, h)))) => {
|
||
log::info!("Pasted image: {}x{}", w, h);
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let canvas_w = doc.engine.canvas_width();
|
||
let canvas_h = doc.engine.canvas_height();
|
||
if w > canvas_w || h > canvas_h {
|
||
// Stash and ask before pasting — paste would crop on transform commit.
|
||
doc.pending_paste = Some((pixels, w, h));
|
||
self.active_dialog = ActiveDialog::ExpandCanvas {
|
||
paste_w: w,
|
||
paste_h: h,
|
||
canvas_w,
|
||
canvas_h,
|
||
};
|
||
} else {
|
||
// Fits — create the floating transform now (existing behavior).
|
||
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
|
||
doc.transform_selection_baseline =
|
||
doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||
doc.transform_source = None;
|
||
doc.transform_preview_base = Some(doc.composite_raw.clone());
|
||
doc.selection_transform =
|
||
selection::clipboard::centered_transform(pixels, w, h, canvas_w, canvas_h);
|
||
render_transform_preview(doc, None);
|
||
}
|
||
}
|
||
Message::ImagePasted(Ok(None)) => {
|
||
log::info!("No image in clipboard");
|
||
}
|
||
Message::ImagePasted(Err(e)) => {
|
||
log::error!("Failed to paste image: {}", e);
|
||
}
|
||
Message::ImagePastedAsNewLayer(Ok(Some((pixels, w, h)))) => {
|
||
log::info!("Paste as new layer: {}x{}", w, h);
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let canvas_w = doc.engine.canvas_width();
|
||
let canvas_h = doc.engine.canvas_height();
|
||
if w > canvas_w || h > canvas_h {
|
||
// Stash and ask before pasting — need canvas expansion.
|
||
doc.pending_paste = Some((pixels, w, h));
|
||
doc.pending_paste_as_new_layer = true;
|
||
self.active_dialog = ActiveDialog::ExpandCanvas {
|
||
paste_w: w,
|
||
paste_h: h,
|
||
canvas_w,
|
||
canvas_h,
|
||
};
|
||
} else {
|
||
// Fits — create new layer with centered pixels directly.
|
||
let centered = selection::clipboard::centered_layer_pixels(
|
||
&pixels, w, h, canvas_w, canvas_h,
|
||
);
|
||
let id = doc.engine.add_layer("Pasted Layer");
|
||
doc.engine.set_layer_pixels(id, centered);
|
||
doc.engine.set_active_layer(id);
|
||
doc.modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
Message::ImagePastedAsNewLayer(Ok(None)) => {
|
||
log::info!("No image in clipboard for paste-as-new-layer");
|
||
}
|
||
Message::ImagePastedAsNewLayer(Err(e)) => {
|
||
log::error!("Failed to paste as new layer: {}", e);
|
||
}
|
||
|
||
// ── File I/O (with rfd) ───────────────────────
|
||
Message::OpenFileRfd => {
|
||
self.pending_file_operation = Some(PendingFileOperation::Open);
|
||
return Task::perform(
|
||
async {
|
||
rfd::AsyncFileDialog::new()
|
||
.set_title("Open Image")
|
||
.add_filter(
|
||
"All Supported Types",
|
||
&[
|
||
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
||
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
||
"hcie", "psd", "kra",
|
||
],
|
||
)
|
||
.add_filter(
|
||
"Images",
|
||
&[
|
||
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
||
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
||
],
|
||
)
|
||
.add_filter("HCIE Native", &["hcie"])
|
||
.add_filter("Photoshop", &["psd"])
|
||
.add_filter("Krita", &["kra"])
|
||
.pick_file()
|
||
.await
|
||
.map(|handle| handle.path().to_path_buf())
|
||
},
|
||
Message::FileDialogClosed,
|
||
);
|
||
}
|
||
Message::SaveFileAs => {
|
||
if !matches!(
|
||
self.pending_file_operation,
|
||
Some(PendingFileOperation::SaveAs { close_after: true })
|
||
) {
|
||
self.pending_file_operation =
|
||
Some(PendingFileOperation::SaveAs { close_after: false });
|
||
}
|
||
return Task::perform(
|
||
async {
|
||
rfd::AsyncFileDialog::new()
|
||
.set_title("Save As")
|
||
.add_filter("HCIE Native", &["hcie"])
|
||
.add_filter("Photoshop", &["psd"])
|
||
.add_filter("Krita", &["kra"])
|
||
.add_filter("PNG", &["png"])
|
||
.add_filter("JPEG", &["jpg", "jpeg"])
|
||
.add_filter("WebP", &["webp"])
|
||
.add_filter("AVIF", &["avif"])
|
||
.add_filter("BMP", &["bmp"])
|
||
.add_filter("GIF", &["gif"])
|
||
.add_filter("TIFF", &["tiff", "tif"])
|
||
.add_filter("TGA", &["tga"])
|
||
.add_filter("OpenEXR", &["exr"])
|
||
.add_filter(
|
||
"All Supported Types",
|
||
&[
|
||
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
|
||
"avif", "tga", "exr", "psd", "kra", "hcie",
|
||
],
|
||
)
|
||
.save_file()
|
||
.await
|
||
.map(|handle| handle.path().to_path_buf())
|
||
},
|
||
Message::FileDialogClosed,
|
||
);
|
||
}
|
||
Message::FileDialogClosed(Some(path)) => {
|
||
let path_str = path.to_string_lossy().to_string();
|
||
|
||
match self.pending_file_operation.take() {
|
||
Some(PendingFileOperation::Open) => {
|
||
match self.open_path_in_new_tab(path, true) {
|
||
Ok(_) => {
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Err(e) => {
|
||
log::error!("Failed to open: {}", e);
|
||
}
|
||
}
|
||
}
|
||
Some(PendingFileOperation::SaveAs { close_after }) => {
|
||
match self.save_document_to(self.active_doc, &path) {
|
||
Ok(()) => {
|
||
log::info!("Saved to {}", path_str);
|
||
self.finish_successful_save(self.active_doc, path);
|
||
if close_after {
|
||
return self.continue_close_after_save();
|
||
}
|
||
}
|
||
Err(error) => {
|
||
log::error!("Failed to save: {error}");
|
||
self.save_error_state =
|
||
Some(crate::dialogs::save_error::SaveErrorState {
|
||
operation: "Save As".to_string(),
|
||
path: path.clone(),
|
||
error: error.clone(),
|
||
suggestion: Self::suggest_alternative_path(&path),
|
||
});
|
||
self.active_dialog = ActiveDialog::SaveError;
|
||
}
|
||
}
|
||
}
|
||
None => log::warn!("Ignoring file dialog result without a pending operation"),
|
||
}
|
||
}
|
||
Message::FileDialogClosed(None) => {
|
||
self.pending_file_operation = None;
|
||
self.pending_document_close = None;
|
||
}
|
||
|
||
// ── Dock Layout ───────────────────────────────
|
||
Message::PaneClicked(pane) => {
|
||
self.dock.active_auto_hide = None;
|
||
self.dock.focus(pane);
|
||
}
|
||
Message::DockHeaderDragStart(pane) => {
|
||
if let Some(source_type) = self
|
||
.dock
|
||
.pane_type(pane)
|
||
.filter(|panel| *panel != PaneType::Canvas)
|
||
{
|
||
let origin = self
|
||
.last_cursor_pos
|
||
.map(|(x, y)| iced::Point::new(x, y))
|
||
.unwrap_or_default();
|
||
self.dock.header_drag = Some(crate::dock::manager::DockHeaderDragState {
|
||
source_pane: pane,
|
||
source_type,
|
||
origin,
|
||
});
|
||
self.dock.focus(pane);
|
||
}
|
||
}
|
||
Message::PaneDragged(drag_event) => {
|
||
use iced::widget::pane_grid::DragEvent;
|
||
match drag_event {
|
||
DragEvent::Picked { pane } => {
|
||
if let Some(source_type) = self
|
||
.dock
|
||
.pane_type(pane)
|
||
.filter(|panel| *panel != PaneType::Canvas)
|
||
{
|
||
self.dock.drag = Some(DockDragState {
|
||
source_pane: pane,
|
||
source_type,
|
||
pointer: self.last_cursor_pos.map(|(x, y)| iced::Point::new(x, y)),
|
||
candidate_target: None,
|
||
accepted_preview: None,
|
||
});
|
||
}
|
||
}
|
||
DragEvent::Dropped { pane, target } => {
|
||
let approved = self.dock.drag.as_ref().is_some_and(|drag| {
|
||
drag.source_pane == pane
|
||
&& drag.candidate_target.is_some_and(|candidate| {
|
||
crate::dock::preview::target_matches(candidate, target)
|
||
&& crate::dock::manager::DockDropPolicy::accepts(
|
||
drag.source_type,
|
||
crate::dock::manager::DockDropZone::from_target(target),
|
||
)
|
||
})
|
||
});
|
||
self.dock.drag = None;
|
||
if approved && self.dock.apply_drop(pane, target) {
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
DragEvent::Canceled { .. } => self.dock.drag = None,
|
||
}
|
||
}
|
||
Message::PaneResized(resize_event) => {
|
||
let grid_size = self.dock.last_grid_size.unwrap_or_else(|| {
|
||
crate::dock::preview::DockGeometry {
|
||
viewport: iced::Size::new(
|
||
self.settings.window.width,
|
||
self.settings.window.height,
|
||
),
|
||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||
}
|
||
.grid_bounds()
|
||
.size()
|
||
});
|
||
let ratio = crate::dock::sizing::constrain_user_resize_ratio(
|
||
&self.dock.pane_grid,
|
||
resize_event.split,
|
||
resize_event.ratio,
|
||
grid_size,
|
||
);
|
||
self.dock.pane_grid.resize(resize_event.split, ratio);
|
||
self.dock.resize_persistence_dirty = true;
|
||
}
|
||
Message::PaneClose(pane) => {
|
||
// Don't close canvas panes
|
||
if let Some(PaneType::Canvas) = self.dock.pane_type(pane) {
|
||
return Task::none();
|
||
}
|
||
if self.dock.close_pane(pane) {
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
Message::PaneMaximize(pane) => {
|
||
self.dock.toggle_maximize(pane);
|
||
}
|
||
Message::PaneAutoHide(pane) => {
|
||
if self
|
||
.dock
|
||
.auto_hide_pane(pane, Some(crate::dock::manager::DockEdge::Right))
|
||
{
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
Message::PaneFloat(pane) => {
|
||
let viewport = (self.settings.window.width, self.settings.window.height);
|
||
if self.dock.float_pane(pane, viewport) {
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
Message::FloatingFocus(panel) => {
|
||
self.dock.focus_floating(panel);
|
||
}
|
||
Message::FloatingDragStart(panel) => {
|
||
if let Some(rect) = self.dock.focus_floating(panel) {
|
||
let pointer = self.last_cursor_pos.unwrap_or((rect.x, rect.y));
|
||
self.dock.floating_drag = Some(crate::dock::floating::FloatingDragState {
|
||
panel,
|
||
pointer_offset: iced::Point::new(pointer.0 - rect.x, pointer.1 - rect.y),
|
||
});
|
||
self.dock.floating_target = None;
|
||
}
|
||
}
|
||
Message::FloatingRedock(panel) => {
|
||
if self.dock.redock_floating(panel) {
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
Message::FloatingClose(panel) => {
|
||
if self.dock.close_floating(panel) {
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
Message::FloatingAutoHide(panel) => {
|
||
if self
|
||
.dock
|
||
.auto_hide_floating(panel, crate::dock::manager::DockEdge::Right)
|
||
{
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
Message::DockViewportChanged(size) => {
|
||
self.settings.window.width = size.width;
|
||
self.settings.window.height = size.height;
|
||
self.dock.clamp_floating((size.width, size.height));
|
||
let grid_size = crate::dock::preview::DockGeometry {
|
||
viewport: size,
|
||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||
}
|
||
.grid_bounds()
|
||
.size();
|
||
self.dock.set_grid_size(grid_size);
|
||
}
|
||
Message::AutoHideActivate(panel) => {
|
||
self.dock.active_auto_hide = if self.dock.active_auto_hide == Some(panel) {
|
||
None
|
||
} else {
|
||
Some(panel)
|
||
};
|
||
}
|
||
Message::AutoHideDismiss => self.dock.active_auto_hide = None,
|
||
Message::AutoHideRestore(panel) => {
|
||
if self.dock.restore_auto_hidden(panel) {
|
||
self.settings
|
||
.panel_layout
|
||
.persist_current_placement(&self.dock);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
|
||
// ── Dock Profiles ─────────────────────────────
|
||
Message::DockProfileSave(name) => {
|
||
let name = name.trim().to_string();
|
||
if name.is_empty() {
|
||
return Task::none();
|
||
}
|
||
// Collect the list of panel types currently open in the dock
|
||
let visible_panels: Vec<String> = self
|
||
.dock
|
||
.pane_grid
|
||
.iter()
|
||
.map(|(_, pane_type)| pane_type.label().to_string())
|
||
.collect();
|
||
let profile = crate::settings::DockProfile {
|
||
name: name.clone(),
|
||
visible_panels,
|
||
left_col1_w: 0.0,
|
||
left_col2_w: 0.0,
|
||
right_col1_w: 0.0,
|
||
right_col2_w: 0.0,
|
||
last_window_width: 0.0,
|
||
};
|
||
self.settings.dock_profiles.retain(|p| p.name != name);
|
||
self.settings.dock_profiles.push(profile);
|
||
self.active_dock_profile = Some(name.clone());
|
||
self.dock_profile_new_name.clear();
|
||
log::info!("Dock profile saved: {}", name);
|
||
let _ = self.settings.save();
|
||
self.active_dialog = ActiveDialog::None;
|
||
}
|
||
Message::DockProfileLoad(name) => {
|
||
if let Some(profile) = self.settings.dock_profiles.iter().find(|p| p.name == *name)
|
||
{
|
||
// Reopen all panels listed in the saved profile
|
||
let panel_names = profile.visible_panels.clone();
|
||
self.dock = crate::dock::state::DockState::new();
|
||
for panel_name in &panel_names {
|
||
if let Some(pane_type) = pane_type_from_label(panel_name) {
|
||
self.dock.reopen_pane(pane_type);
|
||
}
|
||
}
|
||
self.active_dock_profile = Some(name.clone());
|
||
log::info!("Dock profile applied: {}", name);
|
||
}
|
||
}
|
||
Message::DockProfileRename(old_name, new_name) => {
|
||
let new_name = new_name.trim().to_string();
|
||
if new_name.is_empty() {
|
||
return Task::none();
|
||
}
|
||
if let Some(profile) = self
|
||
.settings
|
||
.dock_profiles
|
||
.iter_mut()
|
||
.find(|p| p.name == old_name)
|
||
{
|
||
profile.name = new_name.clone();
|
||
if self.active_dock_profile.as_deref() == Some(&old_name) {
|
||
self.active_dock_profile = Some(new_name);
|
||
}
|
||
log::info!("Dock profile renamed: {} -> {}", old_name, profile.name);
|
||
let _ = self.settings.save();
|
||
}
|
||
self.dock_profile_rename_idx = None;
|
||
self.dock_profile_rename_buf.clear();
|
||
}
|
||
Message::DockProfileDelete(name) => {
|
||
let before = self.settings.dock_profiles.len();
|
||
self.settings.dock_profiles.retain(|p| p.name != *name);
|
||
if self.settings.dock_profiles.len() < before {
|
||
if self.active_dock_profile.as_deref() == Some(&name) {
|
||
self.active_dock_profile = None;
|
||
}
|
||
log::info!("Dock profile deleted: {}", name);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
Message::DockProfileRenameStart(name) => {
|
||
if let Some(idx) = self
|
||
.settings
|
||
.dock_profiles
|
||
.iter()
|
||
.position(|p| p.name == name)
|
||
{
|
||
self.dock_profile_rename_idx = Some(idx);
|
||
self.dock_profile_rename_buf = name;
|
||
}
|
||
}
|
||
Message::DockProfileRenameConfirm => {
|
||
if let Some(idx) = self.dock_profile_rename_idx {
|
||
if idx < self.settings.dock_profiles.len() {
|
||
let old_name = self.settings.dock_profiles[idx].name.clone();
|
||
let new_name = self.dock_profile_rename_buf.trim().to_string();
|
||
if !new_name.is_empty() {
|
||
self.settings.dock_profiles[idx].name = new_name.clone();
|
||
if self.active_dock_profile.as_deref() == Some(&old_name) {
|
||
self.active_dock_profile = Some(new_name);
|
||
}
|
||
log::info!(
|
||
"Dock profile renamed: {} -> {}",
|
||
old_name,
|
||
self.settings.dock_profiles[idx].name
|
||
);
|
||
let _ = self.settings.save();
|
||
}
|
||
}
|
||
}
|
||
self.dock_profile_rename_idx = None;
|
||
self.dock_profile_rename_buf.clear();
|
||
}
|
||
Message::DockProfileRenameCancel => {
|
||
self.dock_profile_rename_idx = None;
|
||
self.dock_profile_rename_buf.clear();
|
||
}
|
||
Message::DockProfileNewNameChanged(name) => {
|
||
self.dock_profile_new_name = name;
|
||
}
|
||
Message::DockProfileRenameChanged(name) => {
|
||
self.dock_profile_rename_buf = name;
|
||
}
|
||
Message::DockProfileDialogShow => {
|
||
self.show_dock_profile_dialog = true;
|
||
}
|
||
Message::DockProfileDialogHide => {
|
||
self.show_dock_profile_dialog = false;
|
||
self.dock_profile_rename_idx = None;
|
||
self.dock_profile_rename_buf.clear();
|
||
}
|
||
|
||
// ── File I/O ─────────────────────────────────
|
||
Message::NewDocument(w, h) => {
|
||
self.show_welcome = false;
|
||
let mut engine = Engine::new(w, h);
|
||
engine.pre_tile_all_layers();
|
||
let composite_raw = engine.get_composite_pixels();
|
||
let cached_layers = engine.layer_infos();
|
||
let history_len = engine.history_len();
|
||
let cached_history: Vec<(usize, String)> = (0..history_len)
|
||
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
||
.collect();
|
||
let history_current = engine.history_current();
|
||
self.documents.push(IcedDocument {
|
||
engine,
|
||
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
|
||
composite_raw.clone(),
|
||
),
|
||
composite_raw,
|
||
name: "Untitled".to_string(),
|
||
source_path: None,
|
||
modified: false,
|
||
zoom: 1.0,
|
||
pan_offset: Vector::ZERO,
|
||
cached_layers,
|
||
cached_history,
|
||
history_current,
|
||
selection_rect: None,
|
||
vector_draw: None,
|
||
pane_size: (800.0, 600.0),
|
||
render_generation: 0,
|
||
dirty_region: std::cell::RefCell::new(None),
|
||
full_upload: std::cell::RefCell::new(true),
|
||
selection_transform: None,
|
||
transform_drag_handle: TransformHandle::None,
|
||
transform_drag_start: None,
|
||
transform_original: None,
|
||
transform_source: None,
|
||
transform_layer_baseline: None,
|
||
transform_selection_baseline: None,
|
||
transform_preview_base: None,
|
||
internal_clipboard: None,
|
||
pending_paste: None,
|
||
pending_paste_as_new_layer: false,
|
||
selection_mask: None,
|
||
selection_texture: None,
|
||
selection_model_dirty: false,
|
||
selection_mask_dirty: std::cell::Cell::new(false),
|
||
selection_bounds: None,
|
||
selection_history: Vec::new(),
|
||
selection_history_marker: 0,
|
||
crop_state: CropState::default(),
|
||
lasso_points: Vec::new(),
|
||
polygon_points: Vec::new(),
|
||
gradient_drag: None,
|
||
vision_rect: None,
|
||
text_draft: None,
|
||
selection_mode: selection::SelectionMode::Replace,
|
||
saved_selection_mask: None,
|
||
quick_mask: false,
|
||
selected_vector_shape: None,
|
||
vector_cycle_index: 0,
|
||
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
|
||
vector_drag_preview: None,
|
||
rotation_snap_active: false,
|
||
rotation_snap_angle: 0.0,
|
||
resize_snap_active: false,
|
||
layer_thumbnails: std::collections::HashMap::new(),
|
||
pending_thumbnail_layers: std::collections::HashSet::new(),
|
||
thumb_gen: 0,
|
||
});
|
||
self.active_doc = self.documents.len() - 1;
|
||
}
|
||
|
||
Message::DocSwitch(idx) => {
|
||
if idx < self.documents.len() {
|
||
if self.preview_baseline.is_some() {
|
||
self.restore_preview();
|
||
self.filter_preview_active = false;
|
||
self.selected_filter = None;
|
||
self.filter_params = serde_json::json!({});
|
||
}
|
||
self.active_doc = idx;
|
||
self.documents[idx].full_upload.replace(true);
|
||
self.documents[idx].selection_mask_dirty.set(true);
|
||
}
|
||
}
|
||
|
||
Message::DocClose(idx) => {
|
||
let modified = self
|
||
.documents
|
||
.get(idx)
|
||
.is_some_and(|document| document.modified);
|
||
match document_close_disposition(self.documents.len(), idx, modified) {
|
||
DocumentCloseDisposition::Invalid => return Task::none(),
|
||
DocumentCloseDisposition::Welcome
|
||
| DocumentCloseDisposition::Confirm
|
||
| DocumentCloseDisposition::Close => {
|
||
if self.preview_baseline.is_some() {
|
||
self.restore_preview();
|
||
self.filter_preview_active = false;
|
||
}
|
||
}
|
||
}
|
||
if modified {
|
||
self.active_doc = idx;
|
||
self.documents[idx].full_upload.replace(true);
|
||
self.documents[idx].selection_mask_dirty.set(true);
|
||
self.pending_document_close = Some(idx);
|
||
self.active_dialog = ActiveDialog::CloseConfirm;
|
||
} else {
|
||
self.close_document_tab(idx);
|
||
}
|
||
}
|
||
|
||
Message::OpenFile => {
|
||
return self.update(Message::OpenFileRfd);
|
||
}
|
||
|
||
Message::FileOpened(Ok(path)) => match self.open_path_in_new_tab(path, true) {
|
||
Ok(_) => return Task::perform(async {}, |_| Message::CompositeRefresh),
|
||
Err(error) => log::error!("Failed to open file: {error}"),
|
||
},
|
||
|
||
Message::FileOpened(Err(e)) => {
|
||
log::error!("Failed to open file: {}", e);
|
||
}
|
||
|
||
Message::SaveFile => {
|
||
if let Some(path) = self.documents[self.active_doc].source_path.clone() {
|
||
match self.save_document_to(self.active_doc, &path) {
|
||
Ok(()) => self.finish_successful_save(self.active_doc, path),
|
||
Err(error) => {
|
||
log::error!("Failed to save: {error}");
|
||
self.save_error_state =
|
||
Some(crate::dialogs::save_error::SaveErrorState {
|
||
operation: "Save".to_string(),
|
||
path: path.clone(),
|
||
error: error.clone(),
|
||
suggestion: Self::suggest_alternative_path(&path),
|
||
});
|
||
self.active_dialog = ActiveDialog::SaveError;
|
||
}
|
||
}
|
||
} else {
|
||
return self.update(Message::SaveFileAs);
|
||
}
|
||
}
|
||
|
||
// ── Menu ─────────────────────────────────────
|
||
Message::MenuOpen(idx) => {
|
||
if self.active_menu == Some(idx) {
|
||
self.active_menu = None;
|
||
} else {
|
||
self.active_menu = Some(idx);
|
||
}
|
||
}
|
||
Message::MenuClose => {
|
||
// If crop tool is active with an active crop, cancel it first
|
||
if self.tool_state.active_tool == Tool::Crop
|
||
&& self.documents[self.active_doc].crop_state.active
|
||
{
|
||
self.documents[self.active_doc].crop_state.cancel();
|
||
} else if self.documents[self.active_doc]
|
||
.selection_transform
|
||
.is_some()
|
||
{
|
||
return self.update(Message::TransformCancel);
|
||
} else if self.active_menu.is_some() {
|
||
self.active_menu = None;
|
||
} else if self.active_dialog != ActiveDialog::None {
|
||
self.active_dialog = ActiveDialog::None;
|
||
}
|
||
}
|
||
Message::MenuCommand(crate::panels::menus::MenuCommand::OpenRecent(path_str)) => {
|
||
self.active_menu = None;
|
||
let path = std::path::PathBuf::from(&path_str);
|
||
if path.exists() {
|
||
match self.open_path_in_new_tab(path, true) {
|
||
Ok(_) => return Task::perform(async {}, |_| Message::CompositeRefresh),
|
||
Err(error) => log::error!("Failed to open recent file: {error}"),
|
||
}
|
||
} else {
|
||
log::error!("Recent file no longer exists: {}", path_str);
|
||
}
|
||
}
|
||
Message::MenuCommand(crate::panels::menus::MenuCommand::ClearRecent) => {
|
||
self.active_menu = None;
|
||
self.recent_files.clear();
|
||
save_recent_files(&self.recent_files);
|
||
}
|
||
Message::MenuCommand(command) => {
|
||
use crate::panels::menus::MenuCommand;
|
||
self.active_menu = None;
|
||
match command {
|
||
MenuCommand::NewDocument => {
|
||
return Task::perform(async {}, |_| {
|
||
Message::DialogOpen(ActiveDialog::NewImage)
|
||
})
|
||
}
|
||
MenuCommand::OpenFile => {
|
||
return Task::perform(async {}, |_| Message::OpenFileRfd)
|
||
}
|
||
MenuCommand::Save => return Task::perform(async {}, |_| Message::SaveFile),
|
||
MenuCommand::SaveAs => return Task::perform(async {}, |_| Message::SaveFileAs),
|
||
MenuCommand::Undo => return Task::perform(async {}, |_| Message::Undo),
|
||
MenuCommand::Redo => return Task::perform(async {}, |_| Message::Redo),
|
||
MenuCommand::Copy => return Task::perform(async {}, |_| Message::CopyImage),
|
||
MenuCommand::Cut => return Task::perform(async {}, |_| Message::CutImage),
|
||
MenuCommand::ClearPixels => {
|
||
return Task::perform(async {}, |_| Message::ClearPixels)
|
||
}
|
||
MenuCommand::Paste => return Task::perform(async {}, |_| Message::PasteImage),
|
||
MenuCommand::CanvasSize => {
|
||
let (w, h) = self.documents[self.active_doc].engine.get_canvas_size();
|
||
self.dialog_canvas_size_width = w;
|
||
self.dialog_canvas_size_height = h;
|
||
self.dialog_canvas_size_anchor = (1, 1);
|
||
self.active_dialog = ActiveDialog::CanvasSize;
|
||
}
|
||
MenuCommand::ImageSize => {
|
||
let (w, h) = self.documents[self.active_doc].engine.get_canvas_size();
|
||
self.dialog_image_size_width = w;
|
||
self.dialog_image_size_height = h;
|
||
self.dialog_image_size_original_ratio = w as f32 / h as f32;
|
||
self.dialog_image_size_constrain = true;
|
||
self.active_dialog = ActiveDialog::ImageSize;
|
||
}
|
||
MenuCommand::AddLayer => return Task::perform(async {}, |_| Message::LayerAdd),
|
||
MenuCommand::DeleteLayer => {
|
||
let id = self.documents[self.active_doc].engine.active_layer_id();
|
||
self.documents[self.active_doc].engine.delete_layer(id);
|
||
}
|
||
MenuCommand::LayerStyles => {
|
||
return Task::perform(async {}, |_| Message::OpenLayerStyleDialog)
|
||
}
|
||
MenuCommand::MergeDown => {
|
||
return Task::perform(async {}, |_| Message::LayerMergeDown)
|
||
}
|
||
MenuCommand::Flatten => {
|
||
return Task::perform(async {}, |_| Message::LayerFlatten)
|
||
}
|
||
MenuCommand::SelectAll => {
|
||
return self.update(Message::SelectAll);
|
||
}
|
||
MenuCommand::Deselect => {
|
||
return self.update(Message::Deselect);
|
||
}
|
||
MenuCommand::InvertSelection => {
|
||
self.documents[self.active_doc].engine.selection_invert();
|
||
self.documents[self.active_doc].selection_model_dirty = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::GrowSelection => {
|
||
self.dialog_selection_value = 5.0;
|
||
self.active_dialog = ActiveDialog::SelectionOp("Grow");
|
||
}
|
||
MenuCommand::ShrinkSelection => {
|
||
self.dialog_selection_value = 5.0;
|
||
self.active_dialog = ActiveDialog::SelectionOp("Shrink");
|
||
}
|
||
MenuCommand::FeatherSelection => {
|
||
self.dialog_selection_value = 10.0;
|
||
self.active_dialog = ActiveDialog::SelectionOp("Feather");
|
||
}
|
||
MenuCommand::BorderSelection => {
|
||
self.dialog_selection_value = 5.0;
|
||
self.active_dialog = ActiveDialog::SelectionOp("Border");
|
||
}
|
||
MenuCommand::SmoothSelection => {
|
||
self.dialog_selection_value = 5.0;
|
||
self.active_dialog = ActiveDialog::SelectionOp("Smooth");
|
||
}
|
||
MenuCommand::SelectionMode(mode) => {
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::SelectionModeChanged(mode)
|
||
})
|
||
}
|
||
MenuCommand::SaveSelection => {
|
||
return Task::perform(async {}, |_| Message::SelectionSave)
|
||
}
|
||
MenuCommand::LoadSelection => {
|
||
return Task::perform(async {}, |_| Message::SelectionLoad)
|
||
}
|
||
MenuCommand::ToggleQuickMask => {
|
||
return Task::perform(async {}, |_| Message::QuickMaskToggle)
|
||
}
|
||
MenuCommand::ZoomIn => {
|
||
return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1))
|
||
}
|
||
MenuCommand::ZoomOut => {
|
||
return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1))
|
||
}
|
||
MenuCommand::FitArea => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let fit_x = (doc.pane_size.0 - 24.0) / doc.engine.canvas_width() as f32;
|
||
let fit_y = (doc.pane_size.1 - 24.0) / doc.engine.canvas_height() as f32;
|
||
doc.zoom = fit_x.min(fit_y).clamp(ZOOM_MIN, ZOOM_MAX);
|
||
doc.pan_offset = Vector::ZERO;
|
||
}
|
||
MenuCommand::ActualPixels => {
|
||
return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0))
|
||
}
|
||
MenuCommand::ViewerToggle => {
|
||
return Task::perform(async {}, |_| Message::ViewerToggle)
|
||
}
|
||
MenuCommand::SelectCropTool => {
|
||
return Task::perform(async {}, |_| Message::ToolSelected(Tool::Crop))
|
||
}
|
||
MenuCommand::GaussianBlur => {
|
||
return Task::perform(async {}, |_| {
|
||
Message::FilterSelect(hcie_engine_api::FilterType::GaussianBlur)
|
||
})
|
||
}
|
||
MenuCommand::Mosaic => {
|
||
return Task::perform(async {}, |_| {
|
||
Message::FilterSelect(hcie_engine_api::FilterType::Mosaic)
|
||
})
|
||
}
|
||
MenuCommand::UnsharpMask => {
|
||
return Task::perform(async {}, |_| {
|
||
Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask)
|
||
})
|
||
}
|
||
MenuCommand::ImportFile => {
|
||
return Task::perform(
|
||
async {
|
||
let path = rfd::AsyncFileDialog::new()
|
||
.add_filter(
|
||
"Images",
|
||
&[
|
||
"png", "jpg", "jpeg", "webp", "bmp", "tiff", "psd",
|
||
"kra",
|
||
],
|
||
)
|
||
.pick_file()
|
||
.await
|
||
.map(|h| h.path().to_path_buf());
|
||
path
|
||
},
|
||
|path| {
|
||
if let Some(p) = path {
|
||
Message::FileOpened(Ok(p))
|
||
} else {
|
||
Message::NoOp
|
||
}
|
||
},
|
||
)
|
||
}
|
||
MenuCommand::ImportSvg => {
|
||
return Task::perform(
|
||
async {
|
||
let path = rfd::AsyncFileDialog::new()
|
||
.add_filter("SVG", &["svg"])
|
||
.pick_file()
|
||
.await
|
||
.map(|h| h.path().to_path_buf());
|
||
path
|
||
},
|
||
|path| {
|
||
if let Some(p) = path {
|
||
Message::FileOpened(Ok(p))
|
||
} else {
|
||
Message::NoOp
|
||
}
|
||
},
|
||
)
|
||
}
|
||
MenuCommand::ImportBrushes => {
|
||
return Task::perform(
|
||
async {
|
||
let path = rfd::AsyncFileDialog::new()
|
||
.add_filter("Brushes", &["abr", "kpp"])
|
||
.pick_file()
|
||
.await
|
||
.map(|h| h.path().to_path_buf());
|
||
path
|
||
},
|
||
|path| {
|
||
if let Some(_p) = path {
|
||
// Trigger brush import
|
||
Message::BrushImportAbr
|
||
} else {
|
||
Message::NoOp
|
||
}
|
||
},
|
||
);
|
||
}
|
||
MenuCommand::Export => return Task::perform(async {}, |_| Message::SaveFileAs),
|
||
MenuCommand::PasteSpecial => {
|
||
return Task::perform(async {}, |_| Message::PasteImage)
|
||
}
|
||
MenuCommand::PasteAsNewLayer => {
|
||
return Task::perform(async {}, |_| Message::PasteAsNewLayer)
|
||
}
|
||
MenuCommand::Fill => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let fg = self.fg_color;
|
||
if let Some(mut pixels) = doc.engine.get_active_layer_pixels() {
|
||
let w = doc.engine.canvas_width() as usize;
|
||
let h = doc.engine.canvas_height() as usize;
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let idx = (y * w + x) * 4;
|
||
if idx + 3 < pixels.len() {
|
||
pixels[idx] = fg[0];
|
||
pixels[idx + 1] = fg[1];
|
||
pixels[idx + 2] = fg[2];
|
||
pixels[idx + 3] = fg[3];
|
||
}
|
||
}
|
||
}
|
||
doc.engine.set_active_layer_pixels(pixels);
|
||
}
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::Stroke => {
|
||
log::info!("Stroke requested — not yet implemented");
|
||
}
|
||
MenuCommand::FreeTransform => {
|
||
// Start free transform from current selection mask
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let mask = doc.engine.get_selection_mask().map(|m| m.to_vec());
|
||
if let Some(mask_data) = mask {
|
||
let w = doc.engine.canvas_width();
|
||
let h = doc.engine.canvas_height();
|
||
let tr = crate::selection::SelectionTransform::from_engine(
|
||
&mut doc.engine,
|
||
&mask_data,
|
||
w,
|
||
h,
|
||
);
|
||
// Clear selected pixels from the active layer (cut)
|
||
if let Some(mut layer_pixels) = doc.engine.get_active_layer_pixels() {
|
||
let cw = w as usize;
|
||
let ch = h as usize;
|
||
for y in 0..ch {
|
||
for x in 0..cw {
|
||
let midx = y * cw + x;
|
||
if midx < mask_data.len() && mask_data[midx] > 0 {
|
||
let pidx = midx * 4;
|
||
if pidx + 3 < layer_pixels.len() {
|
||
layer_pixels[pidx..pidx + 3].fill(0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
doc.engine.set_active_layer_pixels(layer_pixels);
|
||
}
|
||
doc.engine.selection_clear();
|
||
set_document_selection_mask(doc, Some(mask_data));
|
||
doc.selection_transform = Some(tr);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
}
|
||
MenuCommand::Preferences => {
|
||
log::info!("Preferences requested — not yet implemented in ICED");
|
||
}
|
||
MenuCommand::Rotate90CW => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let active = doc.engine.active_layer_id();
|
||
doc.engine.rotate_layer(active, 90.0);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::Rotate90CCW => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let active = doc.engine.active_layer_id();
|
||
doc.engine.rotate_layer(active, -90.0);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::Rotate180 => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let active = doc.engine.active_layer_id();
|
||
doc.engine.rotate_layer(active, 180.0);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::FlipHorizontal => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let active = doc.engine.active_layer_id();
|
||
doc.engine.flip_layer_horizontal(active);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::FlipVertical => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
let active = doc.engine.active_layer_id();
|
||
doc.engine.flip_layer_vertical(active);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::ImageCrop => {
|
||
if self.documents[self.active_doc].selection_rect.is_some() {
|
||
return Task::perform(async {}, |_| Message::ToolSelected(Tool::Crop));
|
||
}
|
||
}
|
||
MenuCommand::InvertNegative => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.engine.apply_filter("invert", serde_json::json!({}));
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::AlignLayer(axis) => {
|
||
let align_axis = match axis.as_str() {
|
||
"left" => hcie_engine_api::AlignAxis::Left,
|
||
"right" => hcie_engine_api::AlignAxis::Right,
|
||
"top" => hcie_engine_api::AlignAxis::Top,
|
||
"bottom" => hcie_engine_api::AlignAxis::Bottom,
|
||
"center" | "hcenter" => hcie_engine_api::AlignAxis::HCenter,
|
||
"middle" | "vcenter" => hcie_engine_api::AlignAxis::VCenter,
|
||
_ => hcie_engine_api::AlignAxis::Left,
|
||
};
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.engine.align_active_layer(align_axis);
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::ErodeBorder => {
|
||
self.dialog_selection_value = 5.0;
|
||
self.active_dialog = ActiveDialog::SelectionOp("Erode");
|
||
}
|
||
MenuCommand::FadeBorder => {
|
||
self.dialog_selection_value = 8.0;
|
||
self.active_dialog = ActiveDialog::SelectionOp("Fade");
|
||
}
|
||
MenuCommand::FilterDistort(filter_id) => {
|
||
let ft = filter_id_to_type(&filter_id);
|
||
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
|
||
}
|
||
MenuCommand::FilterNoise(filter_id) => {
|
||
let ft = filter_id_to_type(&filter_id);
|
||
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
|
||
}
|
||
MenuCommand::FilterPixelate(filter_id) => {
|
||
let ft = filter_id_to_type(&filter_id);
|
||
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
|
||
}
|
||
MenuCommand::FilterRender(filter_id) => {
|
||
let ft = filter_id_to_type(&filter_id);
|
||
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
|
||
}
|
||
MenuCommand::FilterRestore(filter_id) => {
|
||
let ft = filter_id_to_type(&filter_id);
|
||
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
|
||
}
|
||
MenuCommand::FilterStylize(filter_id) => {
|
||
let ft = filter_id_to_type(&filter_id);
|
||
return Task::perform(async move {}, move |_| Message::FilterSelect(ft));
|
||
}
|
||
MenuCommand::FilterTexture(filter_id) => {
|
||
// Textures have no FilterType variant — apply directly with
|
||
// hardcoded default params (mirrors EGUI menus.rs:561-655).
|
||
let params = default_texture_params(&filter_id);
|
||
let doc = &mut self.documents[self.active_doc];
|
||
if !doc.engine.active_layer_is_editable() {
|
||
log::warn!("texture apply blocked for locked/group/mask layer");
|
||
return Task::none();
|
||
}
|
||
doc.engine.apply_filter(&filter_id, params);
|
||
doc.modified = true;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
MenuCommand::ResetPan => {
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.pan_offset = iced::Vector::ZERO;
|
||
}
|
||
MenuCommand::OpenDocuments => {
|
||
// Toggle a document list or focus the active doc
|
||
if !self.documents.is_empty() {
|
||
self.active_doc = 0;
|
||
}
|
||
}
|
||
MenuCommand::ResetLayout => {
|
||
self.dock = self.settings.panel_layout.restore_dock();
|
||
}
|
||
MenuCommand::SelectTool(tool) => {
|
||
return Task::perform(async move {}, move |_| Message::ToolSelected(tool))
|
||
}
|
||
MenuCommand::About => {
|
||
self.active_dialog = ActiveDialog::About;
|
||
}
|
||
MenuCommand::TogglePane(pane) => self.dock.toggle_pane(pane),
|
||
MenuCommand::SetTheme(theme) => {
|
||
self.theme_state.set_preset(theme);
|
||
self.settings.theme_preset = theme;
|
||
let _ = self.settings.save();
|
||
}
|
||
MenuCommand::OpenRecent(_) | MenuCommand::ClearRecent => {
|
||
unreachable!("handled before general menu dispatch")
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Window controls ──────────────────────────
|
||
Message::WindowDrag => {
|
||
if let Some(id) = self.window_id {
|
||
return iced::window::drag(id);
|
||
}
|
||
}
|
||
Message::WindowMinimize => {
|
||
if let Some(id) = self.window_id {
|
||
return iced::window::minimize(id, true);
|
||
}
|
||
}
|
||
Message::WindowMaximize => {
|
||
if let Some(id) = self.window_id {
|
||
return iced::window::toggle_maximize(id);
|
||
}
|
||
}
|
||
Message::TitleSearchChanged(text) => {
|
||
self.title_search = text;
|
||
}
|
||
Message::PanelListToggle => {
|
||
self.show_panel_list = !self.show_panel_list;
|
||
}
|
||
Message::WindowClose => {
|
||
self.pending_document_close = None;
|
||
if let Some(document_index) =
|
||
self.documents.iter().position(|document| document.modified)
|
||
{
|
||
self.active_doc = document_index;
|
||
self.active_dialog = ActiveDialog::CloseConfirm;
|
||
} else if let Some(id) = self.window_id {
|
||
return iced::window::close(id);
|
||
}
|
||
}
|
||
Message::WindowId(id) => {
|
||
self.window_id = Some(id);
|
||
if self.screenshot_request.is_some() {
|
||
return Task::perform(
|
||
async {
|
||
std::thread::sleep(std::time::Duration::from_millis(750));
|
||
},
|
||
|_| Message::ScreenshotCapture,
|
||
);
|
||
}
|
||
}
|
||
Message::ScreenshotCapture => {
|
||
if self.screenshot_request.is_none() {
|
||
let timestamp = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map_or(0, |duration| duration.as_secs());
|
||
self.screenshot_request = Some(crate::cli::ScreenshotRequest {
|
||
panel: None,
|
||
floating_panel: None,
|
||
auto_hide_panel: None,
|
||
welcome: false,
|
||
svg_editor: false,
|
||
output: std::path::PathBuf::from(format!(
|
||
"target/screenshots/iced_{timestamp}.png"
|
||
)),
|
||
});
|
||
}
|
||
if let Some(id) = self.window_id {
|
||
return iced::window::screenshot(id).map(Message::ScreenshotReady);
|
||
}
|
||
log::error!("cannot capture screenshot before the window is initialized");
|
||
}
|
||
Message::ScreenshotReady(screenshot) => {
|
||
if let Some(request) = self.screenshot_request.take() {
|
||
let captured = if let Some(panel) = request.panel.as_deref() {
|
||
crate::screenshot::crop_panel(
|
||
&screenshot,
|
||
&self.dock,
|
||
panel,
|
||
self.sidebar_expanded,
|
||
)
|
||
} else {
|
||
Ok(screenshot)
|
||
};
|
||
if let Err(error) = captured.and_then(|captured| {
|
||
crate::screenshot::save_png(&captured, &request.output)
|
||
}) {
|
||
log::error!("{error}");
|
||
} else {
|
||
log::info!("screenshot saved to {}", request.output.display());
|
||
}
|
||
if let Some(id) = self.window_id {
|
||
return iced::window::close(id);
|
||
}
|
||
}
|
||
}
|
||
Message::ThemeChanged(preset) => {
|
||
self.theme_state.set_preset(preset);
|
||
self.settings.theme_preset = preset;
|
||
let _ = self.settings.save();
|
||
}
|
||
|
||
// ── Image Viewer ─────────────────────────────────
|
||
Message::ViewerToggle => {
|
||
self.viewer_active = !self.viewer_active;
|
||
if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() {
|
||
// Initialize to last-used directory, system Pictures, or home on first open
|
||
let start_dir = self
|
||
.settings
|
||
.viewer_last_dir
|
||
.as_ref()
|
||
.map(std::path::PathBuf::from)
|
||
.filter(|p| p.exists())
|
||
.unwrap_or_else(crate::viewer::default_start_dir);
|
||
self.viewer_state = crate::viewer::ViewerState::new(start_dir);
|
||
}
|
||
if self.viewer_active {
|
||
self.viewer_state.load_preview();
|
||
}
|
||
}
|
||
Message::ViewerSetMode(mode) => {
|
||
self.viewer_state.view_mode = mode;
|
||
self.viewer_state.load_preview();
|
||
}
|
||
Message::ViewerNavigate(path) => {
|
||
self.settings.viewer_last_dir = Some(path.to_string_lossy().to_string());
|
||
let _ = self.settings.save();
|
||
self.viewer_state.set_dir(path);
|
||
self.viewer_state.load_preview();
|
||
}
|
||
Message::ViewerSelectFile(path) => {
|
||
// Select the file in the grid
|
||
if let Some(idx) = self.viewer_state.images.iter().position(|p| p == &path) {
|
||
self.viewer_state.active_image_idx = idx;
|
||
self.viewer_state.load_preview();
|
||
self.viewer_state.view_mode = crate::viewer::ViewerMode::Viewer;
|
||
}
|
||
}
|
||
Message::ViewerOpenFile(path) => {
|
||
// Open into a new editor document so browsing never overwrites a dirty canvas.
|
||
match self.open_path_in_new_tab(path.clone(), true) {
|
||
Ok(_) => {
|
||
self.viewer_active = false;
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
Err(e) => {
|
||
log::error!("Failed to open file from viewer: {}", e);
|
||
self.viewer_state.load_error =
|
||
Some(format!("Could not edit {}: {e}", path.display()));
|
||
}
|
||
}
|
||
}
|
||
Message::ViewerRefresh => {
|
||
self.viewer_state.clear_caches();
|
||
self.viewer_state.load_preview();
|
||
}
|
||
Message::ViewerFullscreen(fs) => {
|
||
self.viewer_state.fullscreen = fs;
|
||
if let Some(id) = self.window_id {
|
||
let mode = if fs {
|
||
iced::window::Mode::Fullscreen
|
||
} else {
|
||
iced::window::Mode::Windowed
|
||
};
|
||
return iced::window::change_mode(id, mode);
|
||
}
|
||
}
|
||
Message::ViewerPrev => {
|
||
if self.viewer_active {
|
||
self.viewer_state.prev_image();
|
||
self.viewer_state.load_preview();
|
||
}
|
||
}
|
||
Message::ViewerNext => {
|
||
if self.viewer_active {
|
||
self.viewer_state.next_image();
|
||
self.viewer_state.load_preview();
|
||
}
|
||
}
|
||
Message::ViewerEnter => {
|
||
if self.viewer_active && !self.viewer_state.images.is_empty() {
|
||
let path = self.viewer_state.images[self.viewer_state.active_image_idx].clone();
|
||
return Task::perform(async move {}, move |_| {
|
||
Message::ViewerOpenFile(path.clone())
|
||
});
|
||
}
|
||
}
|
||
Message::ViewerToggleNode(path) => {
|
||
self.viewer_state.toggle_node(&path);
|
||
}
|
||
Message::ViewerThemeToggle => {
|
||
let current = self.theme_state.preset();
|
||
let new_preset = match current {
|
||
crate::theme::ThemePreset::Photopea
|
||
| crate::theme::ThemePreset::Photoshop
|
||
| crate::theme::ThemePreset::ProDark
|
||
| crate::theme::ThemePreset::Amoled => crate::theme::ThemePreset::ProLight,
|
||
crate::theme::ThemePreset::PhotoshopLight
|
||
| crate::theme::ThemePreset::ProLight => crate::theme::ThemePreset::Photopea,
|
||
};
|
||
self.theme_state.set_preset(new_preset);
|
||
self.settings.theme_preset = new_preset;
|
||
let _ = self.settings.save();
|
||
}
|
||
|
||
// ── SVG Editor ──────────────────────────────────
|
||
Message::SvgEditorOpen {
|
||
layer_id,
|
||
shape_idx,
|
||
} => {
|
||
let doc = &self.documents[self.active_doc];
|
||
if let Some(shapes) = doc.engine.active_vector_shapes() {
|
||
if let Some(hcie_engine_api::VectorShape::SvgShape { svg, .. }) =
|
||
shapes.get(shape_idx)
|
||
{
|
||
match crate::panels::svg_editor::parse_editable(svg) {
|
||
Ok(editable) => {
|
||
self.svg_editor_state =
|
||
Some(crate::panels::svg_editor::SvgEditorState {
|
||
layer_id,
|
||
shape_idx,
|
||
original_svg: svg.clone(),
|
||
editable,
|
||
selected_poly: 0,
|
||
selected_node: None,
|
||
zoom: 1.0,
|
||
pan: iced::Vector::ZERO,
|
||
x_input: String::new(),
|
||
y_input: String::new(),
|
||
snap_to_grid: false,
|
||
grid_size: 0.05,
|
||
grid_input: "0.05".to_string(),
|
||
});
|
||
self.active_dialog = ActiveDialog::SvgEditor;
|
||
}
|
||
Err(e) => {
|
||
log::warn!("SVG Editor open failed: {}", e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Message::SvgEditorSelectPoly(idx) => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
if idx < state.editable.polygons.len() {
|
||
state.selected_poly = idx;
|
||
state.selected_node = None;
|
||
state.refresh_coordinate_inputs();
|
||
}
|
||
}
|
||
}
|
||
Message::SvgEditorSelectNode { polygon, node } => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.select_node(polygon, node);
|
||
}
|
||
}
|
||
Message::SvgEditorClearSelection => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.selected_node = None;
|
||
state.refresh_coordinate_inputs();
|
||
}
|
||
}
|
||
Message::SvgEditorMoveNode {
|
||
polygon,
|
||
node,
|
||
x,
|
||
y,
|
||
} => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
let x = state.snapped(x);
|
||
let y = state.snapped(y);
|
||
state.editable.move_node(polygon, node, x, y);
|
||
state.select_node(polygon, node);
|
||
}
|
||
self.sync_svg_editor_preview();
|
||
}
|
||
Message::SvgEditorCanvasReleased => {}
|
||
Message::SvgEditorCanvasAddNode { polygon, edge } => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.editable.add_node(polygon, edge);
|
||
state.select_node(polygon, edge.saturating_add(1));
|
||
}
|
||
self.sync_svg_editor_preview();
|
||
}
|
||
Message::SvgEditorXChanged(input) => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.x_input = input;
|
||
if let Some(node_idx) = state.selected_node {
|
||
if let (Ok(x), Some(node)) = (
|
||
state.x_input.parse::<f32>(),
|
||
state
|
||
.editable
|
||
.polygons
|
||
.get(state.selected_poly)
|
||
.and_then(|poly| poly.get(node_idx))
|
||
.copied(),
|
||
) {
|
||
let x = state.snapped(x);
|
||
state
|
||
.editable
|
||
.move_node(state.selected_poly, node_idx, x, node.y);
|
||
}
|
||
}
|
||
}
|
||
self.sync_svg_editor_preview();
|
||
}
|
||
Message::SvgEditorYChanged(input) => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.y_input = input;
|
||
if let Some(node_idx) = state.selected_node {
|
||
if let (Ok(y), Some(node)) = (
|
||
state.y_input.parse::<f32>(),
|
||
state
|
||
.editable
|
||
.polygons
|
||
.get(state.selected_poly)
|
||
.and_then(|poly| poly.get(node_idx))
|
||
.copied(),
|
||
) {
|
||
let y = state.snapped(y);
|
||
state
|
||
.editable
|
||
.move_node(state.selected_poly, node_idx, node.x, y);
|
||
}
|
||
}
|
||
}
|
||
self.sync_svg_editor_preview();
|
||
}
|
||
Message::SvgEditorPan { dx, dy } => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.pan.x += dx;
|
||
state.pan.y += dy;
|
||
}
|
||
}
|
||
Message::SvgEditorZoom(delta) => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.zoom = (state.zoom * delta).clamp(0.25, 8.0);
|
||
}
|
||
}
|
||
Message::SvgEditorFit => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.zoom = 1.0;
|
||
state.pan = iced::Vector::ZERO;
|
||
}
|
||
}
|
||
Message::SvgEditorSnapChanged(enabled) => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.snap_to_grid = enabled;
|
||
}
|
||
}
|
||
Message::SvgEditorGridChanged(input) => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
state.grid_input = input;
|
||
if let Ok(value) = state.grid_input.parse::<f32>() {
|
||
if value.is_finite() && value > f32::EPSILON {
|
||
state.grid_size = value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Message::SvgEditorAddNode => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
let poly = state.selected_poly;
|
||
let ni = state.selected_node.unwrap_or(0);
|
||
state.editable.add_node(poly, ni);
|
||
state.select_node(poly, ni + 1);
|
||
}
|
||
self.sync_svg_editor_preview();
|
||
}
|
||
Message::SvgEditorRemoveNode => {
|
||
if let Some(state) = &mut self.svg_editor_state {
|
||
if let Some(ni) = state.selected_node {
|
||
let poly = state.selected_poly;
|
||
state.editable.remove_node(poly, ni);
|
||
state.selected_node = None;
|
||
state.refresh_coordinate_inputs();
|
||
}
|
||
}
|
||
self.sync_svg_editor_preview();
|
||
}
|
||
Message::SvgEditorSave => {
|
||
let new_svg = self.svg_editor_state.as_ref().map(|s| s.editable.to_svg());
|
||
if let Some(svg) = new_svg {
|
||
let layer_id = self
|
||
.svg_editor_state
|
||
.as_ref()
|
||
.map(|s| s.layer_id)
|
||
.unwrap_or(0);
|
||
let shape_idx = self
|
||
.svg_editor_state
|
||
.as_ref()
|
||
.map(|s| s.shape_idx)
|
||
.unwrap_or(0);
|
||
let doc = &mut self.documents[self.active_doc];
|
||
doc.engine.set_vector_shape_svg(layer_id, shape_idx, &svg);
|
||
doc.modified = true;
|
||
self.svg_editor_state = None;
|
||
self.active_dialog = ActiveDialog::None;
|
||
self.refresh_composite_if_needed();
|
||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
}
|
||
}
|
||
Message::SvgEditorCancel => {
|
||
if let Some(state) = self.svg_editor_state.as_ref() {
|
||
let layer_id = state.layer_id;
|
||
let shape_idx = state.shape_idx;
|
||
let original_svg = state.original_svg.clone();
|
||
self.documents[self.active_doc].engine.set_vector_shape_svg(
|
||
layer_id,
|
||
shape_idx,
|
||
&original_svg,
|
||
);
|
||
self.refresh_composite_if_needed();
|
||
}
|
||
self.svg_editor_state = None;
|
||
self.active_dialog = ActiveDialog::None;
|
||
}
|
||
|
||
Message::SidebarToggleExpanded => {
|
||
self.sidebar_expanded = !self.sidebar_expanded;
|
||
self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded;
|
||
self.settings.panel_layout.sidebar_width =
|
||
if self.sidebar_expanded { 72.0 } else { 36.0 };
|
||
let grid_size = crate::dock::preview::DockGeometry {
|
||
viewport: iced::Size::new(
|
||
self.settings.window.width,
|
||
self.settings.window.height,
|
||
),
|
||
toolbox_width: if self.sidebar_expanded { 72.0 } else { 36.0 },
|
||
}
|
||
.grid_bounds()
|
||
.size();
|
||
self.dock.set_grid_size(grid_size);
|
||
let _ = self.settings.save();
|
||
}
|
||
|
||
Message::NoOp => {}
|
||
|
||
Message::TabletTouch { x, y, pressed } => {
|
||
// Forward touch/pen position to the shared tablet state.
|
||
// iced 0.13 touch events carry no pressure, so this only updates
|
||
// position/proximity; a finger lift resets pressure to the mouse
|
||
// default (1.0) so the next mouse stroke is full-pressure.
|
||
if let Ok(mut ts) = self.tablet_state.lock() {
|
||
if pressed {
|
||
ts.set_screen_pos(x, y);
|
||
} else {
|
||
ts.reset_pressure();
|
||
}
|
||
}
|
||
if !pressed {
|
||
return Task::perform(async {}, |_| Message::DockReleaseCleanup);
|
||
}
|
||
}
|
||
|
||
// ── Contextual key actions ───────────────────────
|
||
Message::EscapePressed => {
|
||
if self.dock.active_auto_hide.take().is_some() {
|
||
return Task::none();
|
||
}
|
||
// Mirrors egui's Escape handling. Priority: viewer > crop >
|
||
// transform > vector selection > selection > dialog > viewer fallback.
|
||
match overlay_escape_target(false, self.active_menu.is_some()) {
|
||
Some(OverlayEscapeTarget::Subtools) => unreachable!(),
|
||
Some(OverlayEscapeTarget::Menu) => {
|
||
self.active_menu = None;
|
||
return Task::none();
|
||
}
|
||
None => {}
|
||
}
|
||
if self.viewer_active {
|
||
return self.update(Message::ViewerToggle);
|
||
} else if matches!(self.active_dialog, ActiveDialog::SvgEditor) {
|
||
return self.update(Message::SvgEditorCancel);
|
||
} else if self.active_menu.is_some() {
|
||
return Task::none();
|
||
} else if self.documents[self.active_doc].crop_state.active {
|
||
return self.update(Message::CropCancel);
|
||
} else if self.documents[self.active_doc]
|
||
.selection_transform
|
||
.is_some()
|
||
{
|
||
return self.update(Message::TransformCancel);
|
||
} else if self.documents[self.active_doc]
|
||
.selected_vector_shape
|
||
.is_some()
|
||
{
|
||
return self.update(Message::VectorShapeCancel);
|
||
} else if self.documents[self.active_doc].selection_bounds.is_some()
|
||
|| self.documents[self.active_doc].selection_rect.is_some()
|
||
{
|
||
return self.update(Message::Deselect);
|
||
} else if self.active_dialog != ActiveDialog::None {
|
||
return self.update(Message::DialogClose);
|
||
} else {
|
||
return self.update(Message::ViewerToggle);
|
||
}
|
||
}
|
||
Message::EnterPressed => {
|
||
// Mirrors egui's Enter: crop > transform > text-commit > vector-apply > viewer.
|
||
if self.documents[self.active_doc].crop_state.active {
|
||
return self.update(Message::CropConfirm);
|
||
} else if self.documents[self.active_doc]
|
||
.selection_transform
|
||
.is_some()
|
||
{
|
||
return self.update(Message::TransformApply);
|
||
} else if self.active_document_mut().text_draft.is_some() {
|
||
return self.update(Message::TextCommit);
|
||
} else if self.documents[self.active_doc]
|
||
.selected_vector_shape
|
||
.is_some()
|
||
{
|
||
return self.update(Message::VectorShapeApply);
|
||
} else {
|
||
return self.update(Message::ViewerEnter);
|
||
}
|
||
}
|
||
Message::ModifiersChanged(modifiers) => {
|
||
self.modifiers = modifiers;
|
||
}
|
||
Message::SpacePanChanged(held) => {
|
||
self.tool_state.space_pan = held;
|
||
}
|
||
}
|
||
|
||
// Persist colors to disk when they have changed (debounced via the
|
||
// colors_dirty flag so we only write once per change-set, not per frame).
|
||
if self.colors_dirty {
|
||
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
|
||
self.colors_dirty = false;
|
||
}
|
||
|
||
// Lazy-load thumbnails when viewer is active (max LAZY_LOAD_BUDGET per frame)
|
||
if self.viewer_active {
|
||
let batch = crate::viewer::thumbnail_grid::next_load_batch(&self.viewer_state);
|
||
for path in batch {
|
||
self.viewer_state.load_thumbnail(&path);
|
||
}
|
||
}
|
||
|
||
Task::none()
|
||
}
|
||
|
||
// ── View ────────────────────────────────────────────
|
||
|
||
/// Build the UI element tree.
|
||
///
|
||
/// Uses the dock system for the main layout. The title bar, menu bar,
|
||
/// dock grid, and status bar surround the content. Dialogs overlay everything.
|
||
pub fn view(&self) -> Element<'_, Message> {
|
||
let view_start = std::time::Instant::now();
|
||
let colors = self.theme_state.colors();
|
||
|
||
// When the viewer is active, show the full-screen image viewer
|
||
if self.viewer_active {
|
||
let is_light = colors.is_light;
|
||
let viewer_panel = crate::viewer::view(&self.viewer_state, colors, is_light);
|
||
return container(viewer_panel)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.style(move |_theme| iced::widget::container::Style {
|
||
background: Some(iced::Background::Color(colors.bg_app)),
|
||
..Default::default()
|
||
})
|
||
.into();
|
||
}
|
||
|
||
let doc = &self.documents[self.active_doc];
|
||
|
||
// Title bar (now includes menu items and window controls)
|
||
let title_bar = panels::title_bar::view(
|
||
&doc.name,
|
||
doc.modified,
|
||
doc.zoom,
|
||
doc.engine.canvas_width(),
|
||
doc.engine.canvas_height(),
|
||
colors,
|
||
self.active_menu,
|
||
&self.title_search,
|
||
self.show_panel_list,
|
||
&self.runtime_version,
|
||
);
|
||
|
||
// Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge
|
||
let toolbox = crate::sidebar::view(
|
||
&self.tool_state,
|
||
&self.fg_color,
|
||
&self.bg_color,
|
||
colors,
|
||
self.open_subtool_slot,
|
||
&self.slot_last_used,
|
||
self.sidebar_expanded,
|
||
);
|
||
|
||
// Merged toolbar: icon buttons + tool options in one row
|
||
let toolbar = panels::toolbar::view(&self.tool_state, &self.settings, colors);
|
||
|
||
// Dock grid (without toolbox)
|
||
let dock_content = crate::dock::view::dock_view(&self.dock, self);
|
||
|
||
// Main area: toolbox + dock side by side.
|
||
let main_area = iced::widget::row![toolbox, dock_content]
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.spacing(2);
|
||
|
||
// Status bar
|
||
let status_bar = panels::status_bar::view(
|
||
&self.tool_state.active_tool,
|
||
self.canvas_cursor_pos,
|
||
doc.zoom,
|
||
doc.engine.canvas_width(),
|
||
doc.engine.canvas_height(),
|
||
colors,
|
||
);
|
||
|
||
let content = column![title_bar, toolbar, main_area, status_bar,]
|
||
.width(Length::Fill)
|
||
.height(Length::Fill);
|
||
|
||
let content = container(content)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.style(move |_theme| iced::widget::container::Style {
|
||
background: Some(iced::Background::Color(colors.bg_app)),
|
||
..Default::default()
|
||
});
|
||
|
||
// Dialog overlay
|
||
let dialog_overlay: Element<'_, Message> = match &self.active_dialog {
|
||
ActiveDialog::None => text("").into(),
|
||
ActiveDialog::NewImage => dialogs::new_image::view(
|
||
&self.dialog_new_name,
|
||
self.dialog_new_width,
|
||
self.dialog_new_height,
|
||
self.dialog_new_transparent,
|
||
colors,
|
||
),
|
||
ActiveDialog::BrightnessContrast => dialogs::adjustments::brightness_contrast_view(
|
||
self.dialog_brightness,
|
||
self.dialog_contrast,
|
||
colors,
|
||
),
|
||
ActiveDialog::HueSaturation => dialogs::adjustments::hsl_view(
|
||
self.dialog_hue,
|
||
self.dialog_saturation,
|
||
self.dialog_lightness,
|
||
colors,
|
||
),
|
||
ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name, colors),
|
||
ActiveDialog::SelectionOp(op) => dialogs::confirm::selection_op_view(
|
||
op,
|
||
self.dialog_selection_value,
|
||
1.0,
|
||
100.0,
|
||
colors,
|
||
),
|
||
ActiveDialog::LayerStyleDialog => {
|
||
let doc = &self.documents[self.active_doc];
|
||
let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id());
|
||
let styles: &'static [hcie_engine_api::LayerStyle] =
|
||
Box::leak(styles.into_boxed_slice());
|
||
crate::panels::layer_styles::view(
|
||
styles,
|
||
self.selected_style,
|
||
colors,
|
||
self.show_style_color_picker,
|
||
self.style_color_picker_idx,
|
||
self.style_color_hsl,
|
||
self.layer_style_offset,
|
||
)
|
||
}
|
||
ActiveDialog::ImageSize => dialogs::image_size::view(
|
||
self.dialog_image_size_width,
|
||
self.dialog_image_size_height,
|
||
self.dialog_image_size_constrain,
|
||
colors,
|
||
),
|
||
ActiveDialog::CanvasSize => dialogs::canvas_size::view(
|
||
self.dialog_canvas_size_width,
|
||
self.dialog_canvas_size_height,
|
||
self.dialog_canvas_size_anchor.0,
|
||
self.dialog_canvas_size_anchor.1,
|
||
colors,
|
||
),
|
||
ActiveDialog::About => dialogs::about::view(colors),
|
||
ActiveDialog::SaveError => {
|
||
if let Some(state) = &self.save_error_state {
|
||
dialogs::save_error::view(state, colors)
|
||
} else {
|
||
text("").into()
|
||
}
|
||
}
|
||
ActiveDialog::ExpandCanvas {
|
||
paste_w,
|
||
paste_h,
|
||
canvas_w,
|
||
canvas_h,
|
||
} => dialogs::expand_canvas::view(*paste_w, *paste_h, *canvas_w, *canvas_h, colors),
|
||
ActiveDialog::SvgEditor => {
|
||
if self.svg_editor_state.is_some() {
|
||
// SVG editor overlay rendered separately below
|
||
text("").into()
|
||
} else {
|
||
text("").into()
|
||
}
|
||
}
|
||
};
|
||
|
||
// Stack main content with overlays (dialog + menu dropdown + dock profile dialog).
|
||
// `content` is already wrapped in a styled container with the app
|
||
// background, so no extra wrapper is needed.
|
||
let has_dialog = self.active_dialog != ActiveDialog::None;
|
||
let has_menu = self.active_menu.is_some();
|
||
let has_dock_profile_dialog = self.show_dock_profile_dialog;
|
||
let has_context_menu = self.canvas_context_menu.is_some();
|
||
let has_color_picker = self.color_picker_target.is_some();
|
||
let has_floating = !self.dock.floating.is_empty();
|
||
let has_svg_editor = self.svg_editor_state.is_some()
|
||
&& matches!(self.active_dialog, ActiveDialog::SvgEditor);
|
||
|
||
if has_dialog
|
||
|| has_menu
|
||
|| has_dock_profile_dialog
|
||
|| has_context_menu
|
||
|| has_color_picker
|
||
|| has_floating
|
||
|| has_svg_editor
|
||
{
|
||
let mut stack = iced::widget::Stack::new().push(content);
|
||
for item in &self.dock.floating {
|
||
stack = stack.push(crate::dock::floating::card(item, self, colors));
|
||
}
|
||
if has_dialog {
|
||
stack = stack.push(dialog_overlay);
|
||
}
|
||
if has_svg_editor {
|
||
if let Some(state) = &self.svg_editor_state {
|
||
stack = stack.push(crate::panels::svg_editor::view(state, colors));
|
||
}
|
||
}
|
||
if has_dock_profile_dialog {
|
||
stack = stack.push(dialogs::dock_profile::view(
|
||
&self.settings.dock_profiles,
|
||
self.active_dock_profile.as_deref(),
|
||
&self.dock_profile_new_name,
|
||
colors,
|
||
));
|
||
}
|
||
if let Some(menu_overlay) = panels::menus::dropdown_overlay(
|
||
self.active_menu,
|
||
&self.recent_files,
|
||
self.theme_state.colors(),
|
||
Some(&self.dock),
|
||
self.settings.window.width,
|
||
self.settings.window.height,
|
||
) {
|
||
stack = stack.push(menu_overlay);
|
||
}
|
||
if let Some((cx, cy)) = self.canvas_context_menu {
|
||
stack = stack.push(panels::menus::canvas_context_menu(
|
||
cx,
|
||
cy,
|
||
&self.documents[self.active_doc],
|
||
self.theme_state.colors(),
|
||
));
|
||
}
|
||
if has_color_picker {
|
||
stack = stack.push(crate::color_picker::popup_view(
|
||
self.popup_color,
|
||
&self.popup_color_hex,
|
||
self.popup_color_tab,
|
||
colors,
|
||
));
|
||
}
|
||
let elapsed = view_start.elapsed();
|
||
if !self.tool_state.is_drawing {
|
||
crate::canvas::perf::record_idle_view(elapsed);
|
||
}
|
||
// Performance log measuring Elm view reconstruction time.
|
||
// useful to track widget layout allocation and view model reconstruction times.
|
||
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
|
||
stack.width(Length::Fill).height(Length::Fill).into()
|
||
} else {
|
||
let elapsed = view_start.elapsed();
|
||
if !self.tool_state.is_drawing {
|
||
crate::canvas::perf::record_idle_view(elapsed);
|
||
}
|
||
// Performance log measuring Elm view reconstruction time.
|
||
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
|
||
content.into()
|
||
}
|
||
}
|
||
|
||
// ── Theme & Subscription ────────────────────────────
|
||
|
||
/// Return the application theme.
|
||
pub fn theme(&self) -> Theme {
|
||
if self.theme_state.colors().is_light {
|
||
Theme::Light
|
||
} else {
|
||
Theme::Dark
|
||
}
|
||
}
|
||
|
||
/// Subscriptions — keyboard events for shortcuts.
|
||
///
|
||
/// Handles all keyboard shortcuts matching the egui GUI:
|
||
/// - Ctrl+Z: Undo
|
||
/// - Ctrl+Y / Ctrl+Shift+Z: Redo
|
||
/// - Ctrl+S: Save
|
||
/// - Ctrl+Shift+S: Save As
|
||
/// - Ctrl+N: New
|
||
/// - Ctrl+O: Open
|
||
/// - Ctrl+C: Copy
|
||
/// - Ctrl+V: Paste
|
||
/// - Ctrl+A: Select All
|
||
/// - Ctrl+D: Deselect
|
||
/// - Ctrl+Shift+I: Inverse Selection
|
||
/// - Ctrl++: Zoom In
|
||
/// - Ctrl+-: Zoom Out
|
||
/// - Ctrl+0: Fit to Screen
|
||
/// - Ctrl+1: 100% Zoom
|
||
/// - Delete: Clear selection
|
||
/// - F11: Fullscreen toggle
|
||
/// - B: Brush, E: Eraser, V: Move, M: Marquee, L: Lasso
|
||
/// - W: Magic Wand, T: Text, U: Shape (Vector Rectangle)
|
||
/// - Escape: Close dialog / exit viewer
|
||
/// - Enter: Confirm / open in viewer
|
||
pub fn subscription(&self) -> iced::Subscription<Message> {
|
||
let keyboard = iced::keyboard::on_key_press(|key, modifiers| {
|
||
let ctrl = modifiers.control() || modifiers.logo();
|
||
let shift = modifiers.shift();
|
||
|
||
match key {
|
||
// ── Modifier combos (Ctrl+...) ───────────────
|
||
// Ctrl+Z = Undo
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "z" && ctrl && !shift => {
|
||
Some(Message::Undo)
|
||
}
|
||
// Ctrl+Y or Ctrl+Shift+Z = Redo
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "y" && ctrl && !shift => {
|
||
Some(Message::Redo)
|
||
}
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "z" && ctrl && shift => {
|
||
Some(Message::Redo)
|
||
}
|
||
// Ctrl+S = Save
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "s" && ctrl && !shift => {
|
||
Some(Message::SaveFile)
|
||
}
|
||
// Ctrl+Shift+S = Save As
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "s" && ctrl && shift => {
|
||
Some(Message::SaveFileAs)
|
||
}
|
||
// Ctrl+N = New
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "n" && ctrl && !shift => {
|
||
Some(Message::DialogOpen(ActiveDialog::NewImage))
|
||
}
|
||
// Ctrl+O = Open
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "o" && ctrl && !shift => {
|
||
Some(Message::OpenFileRfd)
|
||
}
|
||
// Ctrl+C = Copy
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "c" && ctrl && !shift => {
|
||
Some(Message::CopyImage)
|
||
}
|
||
// Ctrl+V = Paste
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "v" && ctrl && !shift => {
|
||
Some(Message::PasteImage)
|
||
}
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => {
|
||
Some(Message::CutImage)
|
||
}
|
||
// Ctrl+A = Select All
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "a" && ctrl && !shift => {
|
||
Some(Message::SelectAll)
|
||
}
|
||
// Ctrl+D = Deselect
|
||
iced::keyboard::Key::Character(ref c)
|
||
if c.as_str().eq_ignore_ascii_case("d") && ctrl && !shift =>
|
||
{
|
||
Some(Message::Deselect)
|
||
}
|
||
// Ctrl+Shift+I = Inverse Selection
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "i" && ctrl && shift => {
|
||
Some(Message::SelectInverse)
|
||
}
|
||
// Ctrl++ = Zoom In
|
||
iced::keyboard::Key::Character(ref c)
|
||
if (c.as_str() == "=" || c.as_str() == "+") && ctrl && !shift =>
|
||
{
|
||
Some(Message::CanvasZoomRelative(1.1))
|
||
}
|
||
// Ctrl+- = Zoom Out
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "-" && ctrl && !shift => {
|
||
Some(Message::CanvasZoomRelative(1.0 / 1.1))
|
||
}
|
||
// Ctrl+0 = true fit to the measured canvas pane.
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "0" && ctrl && !shift => {
|
||
Some(Message::FitToArea)
|
||
}
|
||
// Ctrl+1 = 100% Zoom
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "1" && ctrl && !shift => {
|
||
Some(Message::CanvasZoomSet(1.0))
|
||
}
|
||
|
||
// ── Single-key tool shortcuts (no modifiers) ──
|
||
// B = Brush Tool
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "b" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Brush))
|
||
}
|
||
// E = Eraser Tool
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "e" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Eraser))
|
||
}
|
||
// V = Move Tool
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "v" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Move))
|
||
}
|
||
// M = Marquee (Rect Select) Tool
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "m" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Select))
|
||
}
|
||
// L = Lasso Tool
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "l" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Lasso))
|
||
}
|
||
// W = Magic Wand
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "w" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::MagicWand))
|
||
}
|
||
// T = Text Tool
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "t" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Text))
|
||
}
|
||
// U = Shape Tool (Vector Rectangle)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "u" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::VectorRect))
|
||
}
|
||
// P = Pen Tool (egui: app/mod.rs:2963)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "p" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Pen))
|
||
}
|
||
// F = Flood Fill (egui: app/mod.rs:2965)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "f" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::FloodFill))
|
||
}
|
||
// G = Gradient (egui: app/mod.rs:2966)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "g" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Gradient))
|
||
}
|
||
// C = Crop (egui: app/mod.rs:2969)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "c" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Crop))
|
||
}
|
||
// I = Eyedropper (egui: app/mod.rs:2992)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "i" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::Eyedropper))
|
||
}
|
||
// J = Smart Select (egui groups SmartSelect with MagicWand slot)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "j" && !ctrl && !shift => {
|
||
Some(Message::ToolSelected(Tool::SmartSelect))
|
||
}
|
||
// X = Swap fg/bg colors (egui: app/mod.rs:2995)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && !ctrl && !shift => {
|
||
Some(Message::SwapColors)
|
||
}
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "q" && !ctrl && !shift => {
|
||
Some(Message::QuickMaskToggle)
|
||
}
|
||
// Shift+Tab toggles the expanded, scrollable toolbox.
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) if shift && !ctrl => {
|
||
Some(Message::SidebarToggleExpanded)
|
||
}
|
||
// [ = Decrease brush size (egui: app/mod.rs:3001). The closure
|
||
// must be a non-capturing fn, so we emit a relative delta and
|
||
// apply it in update().
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "[" && !ctrl && !shift => {
|
||
Some(Message::BrushSizeRelative(-2.0))
|
||
}
|
||
// ] = Increase brush size (egui: app/mod.rs:3003)
|
||
iced::keyboard::Key::Character(ref c) if c.as_str() == "]" && !ctrl && !shift => {
|
||
Some(Message::BrushSizeRelative(2.0))
|
||
}
|
||
// Y = Crop confirm (handled in app-level via Enter below; Y is
|
||
// reserved — egui maps Enter to crop confirm).
|
||
|
||
// ── Named keys ───────────────────────────────
|
||
// Delete clears selected raster pixels; vector deletion remains available on-canvas.
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete) => {
|
||
Some(Message::ClearPixels)
|
||
}
|
||
// F11 = Fullscreen toggle
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::F11) => {
|
||
Some(Message::WindowMaximize)
|
||
}
|
||
// F12 captures the native Iced viewport through the same path used by CLI audits.
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::F12) => {
|
||
Some(Message::ScreenshotCapture)
|
||
}
|
||
// Tab = Toggle panel visibility (egui: app/mod.rs:3011). We
|
||
// toggle the dock profile dialog open/closed as a stand-in.
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) => {
|
||
Some(Message::DockProfileDialogShow)
|
||
}
|
||
// Arrow keys — viewer navigation
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowLeft) => {
|
||
Some(Message::ViewerPrev)
|
||
}
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowRight) => {
|
||
Some(Message::ViewerNext)
|
||
}
|
||
// Escape = contextual cancel. The closure must be a
|
||
// non-capturing fn, so we emit a dedicated message and resolve
|
||
// the cancel target (viewer/crop/transform/selection/dialog) in
|
||
// update() where `self` is available.
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => {
|
||
Some(Message::EscapePressed)
|
||
}
|
||
// Enter = contextual confirm (crop/transform/text-commit, else
|
||
// viewer open). Resolved in update().
|
||
iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) => {
|
||
Some(Message::EnterPressed)
|
||
}
|
||
_ => None,
|
||
}
|
||
});
|
||
|
||
// Mouse events for dialog dragging, plus touch/pen position feed to the
|
||
// shared tablet state so evdev-less touchscreens still update position.
|
||
let mouse = iced::event::listen_with(|event, status, _id| {
|
||
match event {
|
||
iced::Event::Mouse(mouse_event) => match mouse_event {
|
||
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => (status
|
||
== iced::event::Status::Ignored)
|
||
.then_some(Message::OverlayOutsideClick),
|
||
iced::mouse::Event::CursorMoved { position }
|
||
if status == iced::event::Status::Ignored =>
|
||
{
|
||
Some(Message::DockPointerMoved(position.x, position.y))
|
||
}
|
||
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
|
||
Some(Message::DockPointerReleased)
|
||
}
|
||
_ => None,
|
||
},
|
||
iced::Event::Window(iced::window::Event::Opened { size, .. })
|
||
| iced::Event::Window(iced::window::Event::Resized(size)) => {
|
||
Some(Message::DockViewportChanged(size))
|
||
}
|
||
iced::Event::Touch(touch_event) => {
|
||
// iced 0.13 touch events do not carry a force/pressure field,
|
||
// so we can only forward position. A touch press/move marks
|
||
// the tablet as in-proximity; lifting resets pressure to the
|
||
// mouse default (1.0) so subsequent mouse strokes behave.
|
||
match touch_event {
|
||
iced::touch::Event::FingerPressed { position, .. }
|
||
| iced::touch::Event::FingerMoved { position, .. } => {
|
||
Some(Message::TabletTouch {
|
||
x: position.x,
|
||
y: position.y,
|
||
pressed: true,
|
||
})
|
||
}
|
||
iced::touch::Event::FingerLifted { .. }
|
||
| iced::touch::Event::FingerLost { .. } => Some(Message::TabletTouch {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
pressed: false,
|
||
}),
|
||
}
|
||
}
|
||
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) => {
|
||
Some(Message::ModifiersChanged(modifiers))
|
||
}
|
||
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Space),
|
||
..
|
||
}) => Some(Message::SpacePanChanged(true)),
|
||
iced::Event::Keyboard(iced::keyboard::Event::KeyReleased {
|
||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Space),
|
||
..
|
||
}) => Some(Message::SpacePanChanged(false)),
|
||
_ => None,
|
||
}
|
||
});
|
||
|
||
// Captured canvas events normally stay local to the retained overlay.
|
||
// During a real global drag, forward captured movement so dock/dialog
|
||
// interactions continue across the canvas without rebuilding the app
|
||
// for every idle canvas movement.
|
||
let captured_drag_pointer = if self.layer_style_dragging
|
||
|| self.dock.header_drag.is_some()
|
||
|| self.dock.drag.is_some()
|
||
|| self.dock.floating_drag.is_some()
|
||
{
|
||
iced::event::listen_with(|event, status, _id| match event {
|
||
iced::Event::Mouse(iced::mouse::Event::CursorMoved { position })
|
||
if status == iced::event::Status::Captured =>
|
||
{
|
||
Some(Message::DockPointerMoved(position.x, position.y))
|
||
}
|
||
_ => None,
|
||
})
|
||
} else {
|
||
iced::Subscription::none()
|
||
};
|
||
|
||
// The marching ants are rendered in the persistent GPU canvas shader.
|
||
// Use a real timer rather than an immediately-ready async loop: the old
|
||
// loop generated an unbounded stream of CompositeRefresh messages and
|
||
// starved pointer events while drawing.
|
||
let marching_ants = if self
|
||
.documents
|
||
.iter()
|
||
.any(|doc| doc.selection_bounds.is_some())
|
||
&& !self.tool_state.is_drawing
|
||
{
|
||
iced::time::every(std::time::Duration::from_millis(50))
|
||
.map(|_| Message::CompositeRefresh)
|
||
} else {
|
||
iced::Subscription::none()
|
||
};
|
||
|
||
// Raw pointer messages update the brush engine as quickly as input arrives,
|
||
// while this bounded timer controls expensive compositing and GPU staging.
|
||
let stroke_frames = if should_schedule_canvas_frames(
|
||
self.tool_state.is_drawing,
|
||
self.tool_state.active_tool,
|
||
) {
|
||
iced::time::every(std::time::Duration::from_millis(16))
|
||
.map(|_| Message::CanvasFrameTick)
|
||
} else {
|
||
iced::Subscription::none()
|
||
};
|
||
|
||
iced::Subscription::batch(vec![
|
||
keyboard,
|
||
mouse,
|
||
captured_drag_pointer,
|
||
marching_ants,
|
||
stroke_frames,
|
||
])
|
||
}
|
||
}
|
||
|
||
/// Identifies which transient overlay consumes Escape before editor-level cancellation.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum OverlayEscapeTarget {
|
||
Subtools,
|
||
Menu,
|
||
}
|
||
|
||
/// Resolves Escape priority for transient navigation surfaces.
|
||
///
|
||
/// **Arguments:** `subtools_open` and `menu_open` describe the two transient overlays.
|
||
/// **Returns:** The single overlay to close, preferring the more specific subtool popup.
|
||
/// **Logic & Workflow:** Popup priority is stable even if inconsistent state briefly exposes both.
|
||
/// **Side Effects / Dependencies:** None.
|
||
fn overlay_escape_target(subtools_open: bool, menu_open: bool) -> Option<OverlayEscapeTarget> {
|
||
if subtools_open {
|
||
Some(OverlayEscapeTarget::Subtools)
|
||
} else if menu_open {
|
||
Some(OverlayEscapeTarget::Menu)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Semantic result of pressing one document tab's close control.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum DocumentCloseDisposition {
|
||
Invalid,
|
||
Welcome,
|
||
Confirm,
|
||
Close,
|
||
}
|
||
|
||
/// Resolves tab-close behavior without mutating application state.
|
||
///
|
||
/// Arguments: open document count, requested index, and modified state. Returns: whether to ignore,
|
||
/// show the welcome state, confirm, or remove immediately. Side Effects: None.
|
||
fn document_close_disposition(
|
||
document_count: usize,
|
||
document_index: usize,
|
||
modified: bool,
|
||
) -> DocumentCloseDisposition {
|
||
if document_index >= document_count {
|
||
DocumentCloseDisposition::Invalid
|
||
} else if modified {
|
||
DocumentCloseDisposition::Confirm
|
||
} else if document_count == 1 {
|
||
DocumentCloseDisposition::Welcome
|
||
} else {
|
||
DocumentCloseDisposition::Close
|
||
}
|
||
}
|
||
|
||
/// Computes a safe active index after one tab has been removed.
|
||
///
|
||
/// Arguments: prior active index, removed index, and remaining document count. Returns: a valid
|
||
/// remaining index. Logic & Workflow: shifts indices after the removed tab and selects the nearest
|
||
/// surviving tab when the active one closes. Side Effects: None.
|
||
fn active_index_after_document_close(
|
||
active_index: usize,
|
||
removed_index: usize,
|
||
remaining_count: usize,
|
||
) -> usize {
|
||
debug_assert!(remaining_count > 0);
|
||
if active_index > removed_index {
|
||
active_index - 1
|
||
} else if active_index == removed_index {
|
||
removed_index.min(remaining_count - 1)
|
||
} else {
|
||
active_index.min(remaining_count - 1)
|
||
}
|
||
}
|
||
|
||
/// Determines whether the bounded canvas frame timer should run.
|
||
///
|
||
/// **Arguments:** `is_drawing` is the pointer-drag state and `tool` is the active editor tool.
|
||
/// **Returns:** `true` only for an active raster paint stroke. **Logic & Workflow:** Selection,
|
||
/// vector, transform, and idle states continue to use their event-driven previews.
|
||
/// **Side Effects / Dependencies:** None.
|
||
fn should_schedule_canvas_frames(is_drawing: bool, tool: Tool) -> bool {
|
||
is_drawing && matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod cycle_one_ux_tests {
|
||
use super::{
|
||
active_index_after_document_close, consume_selection_sync_request,
|
||
document_close_disposition, overlay_escape_target, should_schedule_canvas_frames,
|
||
union_regions, DocumentCloseDisposition, HcieIcedApp, Message, OverlayEscapeTarget,
|
||
};
|
||
use hcie_engine_api::Tool;
|
||
|
||
/// Confirms Escape dismisses subtools before menus and leaves editor cancellation untouched.
|
||
#[test]
|
||
fn popup_and_menu_escape_priority_is_stable() {
|
||
assert_eq!(
|
||
overlay_escape_target(true, true),
|
||
Some(OverlayEscapeTarget::Subtools)
|
||
);
|
||
assert_eq!(
|
||
overlay_escape_target(false, true),
|
||
Some(OverlayEscapeTarget::Menu)
|
||
);
|
||
assert_eq!(overlay_escape_target(false, false), None);
|
||
}
|
||
|
||
/// Confirms dirty tabs require confirmation, clean tabs close directly, and the final tab uses
|
||
/// the existing whole-window confirmation path.
|
||
#[test]
|
||
fn document_tab_close_uses_semantic_continuations() {
|
||
assert_eq!(
|
||
document_close_disposition(3, 1, true),
|
||
DocumentCloseDisposition::Confirm
|
||
);
|
||
assert_eq!(
|
||
document_close_disposition(3, 1, false),
|
||
DocumentCloseDisposition::Close
|
||
);
|
||
assert_eq!(
|
||
document_close_disposition(1, 0, false),
|
||
DocumentCloseDisposition::Welcome
|
||
);
|
||
assert_eq!(
|
||
document_close_disposition(2, 2, false),
|
||
DocumentCloseDisposition::Invalid
|
||
);
|
||
}
|
||
|
||
/// Confirms closing tabs before, at, and after the active tab never leaves a stale index.
|
||
#[test]
|
||
fn document_tab_close_keeps_active_index_safe() {
|
||
assert_eq!(active_index_after_document_close(2, 0, 2), 1);
|
||
assert_eq!(active_index_after_document_close(1, 1, 2), 1);
|
||
assert_eq!(active_index_after_document_close(2, 2, 2), 1);
|
||
assert_eq!(active_index_after_document_close(0, 2, 2), 0);
|
||
}
|
||
|
||
/// Ensures an unchanged selection cannot trigger work on every drawing refresh.
|
||
#[test]
|
||
fn selection_sync_request_is_consumed_once() {
|
||
let mut requested = true;
|
||
assert!(consume_selection_sync_request(&mut requested));
|
||
for _ in 0..120 {
|
||
assert!(!consume_selection_sync_request(&mut requested));
|
||
}
|
||
requested = true;
|
||
assert!(consume_selection_sync_request(&mut requested));
|
||
}
|
||
|
||
/// Protects the multi-event dirty accumulator from dropping an earlier update.
|
||
#[test]
|
||
fn dirty_region_union_retains_all_updates_before_view() {
|
||
let first = union_regions(None, [10, 20, 30, 40]);
|
||
let union = union_regions(Some(first), [25, 5, 50, 35]);
|
||
assert_eq!(union, [10, 5, 50, 40]);
|
||
}
|
||
|
||
/// Confirms the frame timer is bounded to active raster strokes.
|
||
#[test]
|
||
fn canvas_frame_timer_runs_only_for_active_paint_strokes() {
|
||
for tool in [Tool::Pen, Tool::Brush, Tool::Eraser, Tool::Spray] {
|
||
assert!(should_schedule_canvas_frames(true, tool));
|
||
assert!(!should_schedule_canvas_frames(false, tool));
|
||
}
|
||
assert!(!should_schedule_canvas_frames(true, Tool::Select));
|
||
assert!(!should_schedule_canvas_frames(true, Tool::VectorSelect));
|
||
}
|
||
|
||
/// Confirms a newly created document becomes the selected tab.
|
||
///
|
||
/// **Purpose:** Guards against a tab-management regression where the new
|
||
/// document existed but later operations still targeted the previous tab.
|
||
/// **Logic & Workflow:** Creates application state, dispatches the normal
|
||
/// `NewDocument` message, and checks both count and active index.
|
||
/// **Arguments & Returns:** None. **Side Effects / Dependencies:** Allocates
|
||
/// two in-memory engine documents and reads ordinary application settings.
|
||
#[test]
|
||
fn new_document_becomes_the_active_tab() {
|
||
let (mut app, _) = HcieIcedApp::new(None, None);
|
||
let previous_count = app.documents.len();
|
||
|
||
let _ = app.update(Message::NewDocument(32, 24));
|
||
|
||
assert_eq!(app.documents.len(), previous_count + 1);
|
||
assert_eq!(app.active_doc, app.documents.len() - 1);
|
||
assert_eq!(app.documents[app.active_doc].engine.canvas_width(), 32);
|
||
assert_eq!(app.documents[app.active_doc].engine.canvas_height(), 24);
|
||
}
|
||
|
||
/// Confirms opening another file appends a tab without mutating the first.
|
||
///
|
||
/// **Purpose:** Reproduces the reported second-document overwrite regression.
|
||
/// **Logic & Workflow:** Writes a tiny PNG, opens it through the shared tab
|
||
/// helper, and verifies the initial document identity and new active tab.
|
||
/// **Arguments & Returns:** None. **Side Effects / Dependencies:** Creates and
|
||
/// removes one temporary image file; recent-file persistence is disabled.
|
||
#[test]
|
||
fn opening_a_second_file_preserves_the_existing_tab() {
|
||
let (mut app, _) = HcieIcedApp::new(None, None);
|
||
let original_name = app.documents[0].name.clone();
|
||
let unique = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos();
|
||
let path =
|
||
std::env::temp_dir().join(format!("hcie-open-tab-{}-{unique}.png", std::process::id()));
|
||
image::save_buffer(
|
||
&path,
|
||
&[255, 0, 0, 255, 0, 255, 0, 255],
|
||
2,
|
||
1,
|
||
image::ColorType::Rgba8,
|
||
)
|
||
.unwrap();
|
||
|
||
let opened_index = app.open_path_in_new_tab(path.clone(), false).unwrap();
|
||
|
||
assert_eq!(app.documents.len(), 2);
|
||
assert_eq!(opened_index, 1);
|
||
assert_eq!(app.active_doc, 1);
|
||
assert_eq!(app.documents[0].name, original_name);
|
||
assert!(app.documents[0].source_path.is_none());
|
||
assert_eq!(
|
||
app.documents[1].source_path.as_deref(),
|
||
Some(path.as_path())
|
||
);
|
||
assert_eq!(app.documents[1].engine.canvas_width(), 2);
|
||
assert_eq!(app.documents[1].engine.canvas_height(), 1);
|
||
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
// ── Recent color proximity dedup ─────────────────────
|
||
|
||
/// Confirms an identical color IS proximate (diff=0 ≤ threshold).
|
||
/// In practice this means an exact duplicate click is deduped, which is
|
||
/// acceptable because the color is already at (or near) the front of the list.
|
||
#[test]
|
||
fn proximate_identical_color_is_skipped() {
|
||
let last = [100, 150, 200, 255];
|
||
let color = [100, 150, 200, 255];
|
||
// Identical → not proximate because the function returns true for
|
||
// threshold ≤2 per channel; identical (diff=0) is within threshold
|
||
// so `is_proximate_to_last` returns true.
|
||
assert!(super::is_proximate_to_last(&last, &color));
|
||
}
|
||
|
||
/// Confirms a slider-step color (+1 in one channel) IS proximate and skipped.
|
||
#[test]
|
||
fn proximate_small_slider_step_skipped() {
|
||
let last = [100, 150, 200, 255];
|
||
let slider_step = [101, 150, 200, 255];
|
||
assert!(super::is_proximate_to_last(&last, &slider_step));
|
||
}
|
||
|
||
/// Confirms a +3 change in all channels is NOT proximate (exceeds threshold).
|
||
#[test]
|
||
fn large_change_not_proximate() {
|
||
let last = [100, 150, 200, 255];
|
||
let far = [105, 155, 205, 255];
|
||
assert!(!super::is_proximate_to_last(&last, &far));
|
||
}
|
||
|
||
/// Confirms a single-channel +3 jump exceeds the threshold.
|
||
#[test]
|
||
fn three_unit_change_exceeds_threshold() {
|
||
let last = [100, 150, 200, 255];
|
||
let three_away = [103, 150, 200, 255];
|
||
assert!(!super::is_proximate_to_last(&last, &three_away));
|
||
}
|
||
|
||
/// Confirms small mixed changes are still within the proximity window.
|
||
#[test]
|
||
fn mixed_small_changes_are_proximate() {
|
||
let last = [100, 150, 200, 255];
|
||
let mixed_small = [101, 151, 201, 255];
|
||
assert!(super::is_proximate_to_last(&last, &mixed_small));
|
||
}
|
||
}
|
||
|
||
/// Find the topmost text layer whose rasterized pixels contain the point
|
||
/// `(x, y)` in canvas coordinates.
|
||
fn _find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<u64> {
|
||
let ux = x.round().clamp(0.0, u32::MAX as f32) as u32;
|
||
let uy = y.round().clamp(0.0, u32::MAX as f32) as u32;
|
||
let infos = engine.layer_infos();
|
||
// layer_infos returns bottom-to-top; iterate in reverse for topmost first.
|
||
for info in infos.iter().rev() {
|
||
if info.layer_type == hcie_engine_api::LayerType::Text && info.visible {
|
||
if let Some(px) = engine.get_pixel(info.id, ux, uy) {
|
||
if px[3] > 10 {
|
||
return Some(info.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|