4085 lines
200 KiB
Rust
4085 lines
200 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.
|
|
|
|
use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT};
|
|
use crate::dialogs;
|
|
use crate::i18n;
|
|
use crate::dock::state::{DockState, PaneType};
|
|
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, BrushTip, BrushStyle, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN};
|
|
use iced::widget::{column, container, text};
|
|
use iced::{Element, Length, Task, Theme, Vector};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
/// Returns the path to the HCIE config directory (~/.config/hcie-iced/).
|
|
/// Creates the directory if it doesn't exist.
|
|
fn config_dir() -> PathBuf {
|
|
let dir = dirs::config_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join("hcie-iced");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
dir
|
|
}
|
|
|
|
/// Returns the path to the recent files JSON config.
|
|
fn recent_files_path() -> PathBuf {
|
|
config_dir().join("recent_files.json")
|
|
}
|
|
|
|
/// Load recent files from disk.
|
|
fn load_recent_files() -> Vec<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);
|
|
}
|
|
}
|
|
|
|
/// Active dialog type.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
#[allow(dead_code)]
|
|
pub enum ActiveDialog {
|
|
None,
|
|
NewImage,
|
|
BrightnessContrast,
|
|
HueSaturation,
|
|
CloseConfirm,
|
|
SelectionOp(&'static str),
|
|
LayerStyleDialog,
|
|
ImageSize,
|
|
CanvasSize,
|
|
About,
|
|
}
|
|
|
|
/// Top-level application state.
|
|
pub struct HcieIcedApp {
|
|
/// All open documents.
|
|
pub documents: Vec<IcedDocument>,
|
|
/// Index of the currently active document.
|
|
pub active_doc: usize,
|
|
/// Current tool state.
|
|
pub tool_state: ToolState,
|
|
/// Foreground drawing color (RGBA).
|
|
pub fg_color: [u8; 4],
|
|
/// Background drawing color (RGBA).
|
|
pub bg_color: [u8; 4],
|
|
/// Last known cursor position from mouse_area events.
|
|
pub last_cursor_pos: Option<(f32, f32)>,
|
|
/// Cursor position in canvas-space for status bar display.
|
|
pub canvas_cursor_pos: Option<(u32, u32)>,
|
|
/// Currently active dialog (if any).
|
|
pub active_dialog: ActiveDialog,
|
|
/// Index of the active tool slot in the sidebar (for sub-tools).
|
|
pub active_tool_slot: usize,
|
|
/// Whether the sub-tools popup is open for the active slot.
|
|
pub sub_tools_open: bool,
|
|
/// 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>,
|
|
/// New Image dialog state.
|
|
pub dialog_new_name: String,
|
|
pub dialog_new_width: u32,
|
|
pub dialog_new_height: u32,
|
|
pub dialog_new_transparent: bool,
|
|
/// Adjustment dialog state.
|
|
pub dialog_brightness: f32,
|
|
pub dialog_contrast: f32,
|
|
pub dialog_hue: f32,
|
|
pub dialog_saturation: f32,
|
|
pub dialog_lightness: f32,
|
|
/// Selection operation dialog state.
|
|
pub dialog_selection_value: f32,
|
|
/// 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,
|
|
/// 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]>,
|
|
/// 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,
|
|
/// 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 — 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,
|
|
}
|
|
|
|
/// A recently opened file entry.
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct RecentFileEntry {
|
|
pub path: String,
|
|
pub name: String,
|
|
}
|
|
|
|
/// Per-document state wrapping an engine instance.
|
|
pub struct IcedDocument {
|
|
pub engine: Engine,
|
|
/// Composite RGBA pixel data shared with the shader pipeline via Arc.
|
|
/// The shader's `prepare()` reads this and uploads only the dirty region
|
|
/// via `queue.write_texture()`. Updated in `refresh_composite_if_needed()`.
|
|
pub composite_pixels: Arc<Vec<u8>>,
|
|
/// Mutable composite buffer for partial-copy from engine.
|
|
/// After updating, this is wrapped in a new Arc for the shader.
|
|
composite_raw: Vec<u8>,
|
|
/// Document display name.
|
|
pub name: String,
|
|
/// Original file path, if loaded from disk.
|
|
pub source_path: Option<std::path::PathBuf>,
|
|
/// Whether the document has unsaved changes.
|
|
pub modified: bool,
|
|
/// Per-document zoom level.
|
|
pub zoom: f32,
|
|
/// Per-document pan offset in screen pixels.
|
|
pub pan_offset: Vector,
|
|
/// Cached layer infos for the layers panel.
|
|
pub cached_layers: Vec<hcie_engine_api::LayerInfo>,
|
|
/// Cached history items for the history panel.
|
|
pub cached_history: Vec<(usize, String)>,
|
|
/// Current history index.
|
|
pub history_current: i32,
|
|
/// Selection rectangle in canvas coordinates (for select tool).
|
|
pub selection_rect: Option<(f32, f32, f32, f32)>,
|
|
/// Vector shape being drawn (start pos, current pos).
|
|
pub vector_draw: Option<((f32, f32), (f32, f32))>,
|
|
/// Canvas pane size (width, height) in pixels — updated by the view.
|
|
pub pane_size: (f32, f32),
|
|
/// Render generation counter; incremented whenever the engine composite
|
|
/// buffer is refreshed.
|
|
pub render_generation: u64,
|
|
/// Dirty region from the last engine composite [x0, y0, x1, y1].
|
|
/// Set by `refresh_composite_if_needed()`, consumed by the shader.
|
|
pub dirty_region: std::cell::RefCell<Option<[u32; 4]>>,
|
|
/// Whether a full texture upload is needed (first frame, resize, file load).
|
|
/// Set to true after loading a file or changing canvas dimensions.
|
|
pub full_upload: std::cell::RefCell<bool>,
|
|
/// Active selection transform (floating pixels after cut/copy-paste)
|
|
pub selection_transform: Option<SelectionTransform>,
|
|
/// Transform handle being dragged
|
|
pub transform_drag_handle: TransformHandle,
|
|
/// Transform drag start position in canvas coordinates
|
|
pub transform_drag_start: Option<(f32, f32)>,
|
|
/// Original transform state before drag (for undo)
|
|
pub transform_original: Option<SelectionTransform>,
|
|
/// Internal clipboard for copy/paste operations
|
|
pub internal_clipboard: Option<SelectionTransform>,
|
|
/// Selection mask (alpha channel) for the current selection
|
|
pub selection_mask: Option<Vec<u8>>,
|
|
/// Selection bounds (x, y, width, height)
|
|
pub selection_bounds: Option<(u32, u32, u32, u32)>,
|
|
/// Crop tool state
|
|
pub crop_state: CropState,
|
|
}
|
|
|
|
/// Drawing tool state.
|
|
pub struct ToolState {
|
|
/// Currently selected tool.
|
|
pub active_tool: Tool,
|
|
/// Whether the user is currently drawing (mouse button held).
|
|
pub is_drawing: bool,
|
|
/// Canvas-space coordinates of the last pointer event during a stroke.
|
|
pub last_stroke_pos: Option<(f32, f32)>,
|
|
/// Accumulated distance for the current stroke (for pressure interpolation).
|
|
pub brush_accumulated_dist: f32,
|
|
/// Current brush size.
|
|
pub brush_size: f32,
|
|
/// Current brush opacity.
|
|
pub brush_opacity: f32,
|
|
/// Current brush hardness.
|
|
pub brush_hardness: f32,
|
|
/// Current brush style.
|
|
pub brush_style: BrushStyle,
|
|
/// Current brush spacing.
|
|
pub brush_spacing: f32,
|
|
/// Timestamp of the last composite refresh for throttling during strokes.
|
|
pub last_composite_refresh: std::time::Instant,
|
|
/// Timestamp of the last update() call for frame timing.
|
|
pub last_update_instant: std::time::Instant,
|
|
/// Imported brush presets from ABR/KPP files.
|
|
pub imported_brushes: Vec<hcie_engine_api::BrushPreset>,
|
|
}
|
|
|
|
impl Default for ToolState {
|
|
fn default() -> Self {
|
|
Self {
|
|
active_tool: Tool::Brush,
|
|
is_drawing: false,
|
|
last_stroke_pos: None,
|
|
brush_accumulated_dist: 0.0,
|
|
brush_size: 20.0,
|
|
brush_opacity: 1.0,
|
|
brush_hardness: 0.8,
|
|
brush_style: BrushStyle::Round,
|
|
brush_spacing: 1.0,
|
|
last_composite_refresh: std::time::Instant::now(),
|
|
last_update_instant: std::time::Instant::now(),
|
|
imported_brushes: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Messages the application handles.
|
|
#[derive(Debug, Clone)]
|
|
#[allow(dead_code)]
|
|
pub enum Message {
|
|
// ── Tool selection ──────────────────────────────────
|
|
ToolSelected(Tool),
|
|
ToolSlotClicked(usize, Tool),
|
|
|
|
// ── Color ───────────────────────────────────────────
|
|
FgColorChanged([u8; 4]),
|
|
BgColorChanged([u8; 4]),
|
|
SwapColors,
|
|
ColorTabChanged(usize),
|
|
|
|
// ── Canvas interaction ──────────────────────────────
|
|
CanvasPointerPressed { x: f32, y: f32 },
|
|
CanvasPointerMoved { x: f32, y: f32 },
|
|
CanvasPointerReleased,
|
|
CanvasPanZoom { zoom: f32, pan_offset: Vector },
|
|
CanvasZoomSet(f32),
|
|
CanvasZoomRelative(f32),
|
|
CanvasSize((f32, f32)),
|
|
CanvasCursorPos { x: u32, y: u32 },
|
|
|
|
// ── Engine operations ───────────────────────────────
|
|
CompositeRefresh,
|
|
Undo,
|
|
Redo,
|
|
|
|
// ── Layer operations ────────────────────────────────
|
|
LayerSelect(u64),
|
|
LayerAdd,
|
|
LayerDelete(u64),
|
|
LayerToggleVisibility(u64),
|
|
LayerToggleLock(u64),
|
|
LayerSetOpacity(u64, f32),
|
|
LayerMergeDown,
|
|
LayerFlatten,
|
|
LayerMoveUp(u64),
|
|
LayerMoveDown(u64),
|
|
LayerSetBlendMode(u64, BlendMode),
|
|
LayerSetFillOpacity(u64, f32),
|
|
LayerAddGroup,
|
|
LayerToggleCollapse(u64),
|
|
|
|
// ── History ─────────────────────────────────────────
|
|
HistoryJumpTo(i32),
|
|
|
|
// ── Text tool ───────────────────────────────────────
|
|
TextSizeChanged(f32),
|
|
TextColorChanged([u8; 4]),
|
|
TextAlignChanged(String),
|
|
TextAccept,
|
|
TextCancel,
|
|
|
|
// ── Layer styles ────────────────────────────────────
|
|
LayerStyleToggle(u32),
|
|
LayerStyleSelect(usize),
|
|
LayerStyleApply,
|
|
LayerStyleCancel,
|
|
LayerStyleUpdateParam(usize, String, f32),
|
|
LayerStyleUpdateBlendMode(usize, String),
|
|
LayerStyleUpdateColor(usize, [u8; 4]),
|
|
LayerStyleUpdateString(usize, String, String),
|
|
LayerStyleUpdateShadowColor(usize, [u8; 4]),
|
|
LayerStyleUpdateShadowBlend(usize, String),
|
|
LayerStyleUpdateShadowOpacity(usize, f32),
|
|
ShowStyleColorPicker(usize),
|
|
ShowStyleShadowColorPicker(usize),
|
|
HideStyleColorPicker,
|
|
OpenLayerStyleDialog,
|
|
LayerStyleDialogDragStart(f32, f32),
|
|
LayerStyleDialogDragMove(f32, f32),
|
|
LayerStyleDialogDragEnd,
|
|
LayerStyleAdd,
|
|
|
|
// ── Brushes ─────────────────────────────────────────
|
|
BrushStyleSelected(BrushStyle),
|
|
BrushSizeChanged(f32),
|
|
BrushOpacityChanged(f32),
|
|
BrushHardnessChanged(f32),
|
|
BrushImportAbr,
|
|
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
|
|
ResetToolDefaults,
|
|
|
|
// ── Filters ─────────────────────────────────────────
|
|
FilterSelect(FilterType),
|
|
FilterApply,
|
|
FilterReset,
|
|
FilterParamChanged(String, serde_json::Value),
|
|
FilterPreviewToggle(bool),
|
|
FilterPreviewApply,
|
|
FilterPreviewRestore,
|
|
|
|
// ── Dialogs ─────────────────────────────────────────
|
|
DialogOpen(ActiveDialog),
|
|
DialogClose,
|
|
DialogCloseSave,
|
|
DialogCloseDiscard,
|
|
|
|
// ── New Image Dialog ────────────────────────────────
|
|
DialogNewImageName(String),
|
|
DialogNewImageWidth(u32),
|
|
DialogNewImageHeight(u32),
|
|
DialogNewImageTransparent(bool),
|
|
DialogNewImagePreset(u32, u32),
|
|
DialogNewImageCreate,
|
|
|
|
// ── Adjustments Dialog ──────────────────────────────
|
|
AdjBrightness(f32),
|
|
AdjContrast(f32),
|
|
AdjHue(f32),
|
|
AdjSaturation(f32),
|
|
AdjLightness(f32),
|
|
AdjApply,
|
|
|
|
// ── Selection Dialog ────────────────────────────────
|
|
SelectionOpValue(f32),
|
|
SelectionOpApply,
|
|
|
|
// ── Image Size Dialog ──────────────────────────────
|
|
DialogImageSizeWidth(u32),
|
|
DialogImageSizeHeight(u32),
|
|
DialogImageSizeConstrain(bool),
|
|
DialogImageSizeApply,
|
|
|
|
// ── Canvas Size Dialog ─────────────────────────────
|
|
DialogCanvasSizeWidth(u32),
|
|
DialogCanvasSizeHeight(u32),
|
|
DialogCanvasSizeAnchor(usize, usize),
|
|
DialogCanvasSizeApply,
|
|
|
|
// ── Selection tools ─────────────────────────────────
|
|
SelectionDragStart(f32, f32),
|
|
SelectionDragMove(f32, f32),
|
|
SelectionDragEnd,
|
|
SelectAll,
|
|
Deselect,
|
|
SelectInverse,
|
|
|
|
// ── Selection transform ─────────────────────────────
|
|
TransformDragStart(f32, f32),
|
|
TransformDragMove(f32, f32),
|
|
TransformDragEnd,
|
|
TransformApply,
|
|
TransformCancel,
|
|
|
|
// ── Vector tools ────────────────────────────────────
|
|
VectorDrawStart(f32, f32),
|
|
VectorDrawMove(f32, f32),
|
|
VectorDrawEnd,
|
|
|
|
// ── Crop tool ──────────────────────────────────────
|
|
CropDragStart(f32, f32),
|
|
CropDragMove(f32, f32),
|
|
CropDragEnd,
|
|
CropConfirm,
|
|
CropCancel,
|
|
|
|
// ── Script Editor ───────────────────────────────────
|
|
ScriptEditorAction(iced::widget::text_editor::Action),
|
|
ScriptTextChanged(String),
|
|
ScriptRun,
|
|
ScriptClear,
|
|
|
|
// ── AI Chat ─────────────────────────────────────────
|
|
AiChatInput(String),
|
|
AiChatSend,
|
|
AiChatClear,
|
|
AiChatProviderChanged(String),
|
|
AiChatUrlChanged(String),
|
|
AiChatModelChanged(String),
|
|
AiChatReasoningToggled(bool),
|
|
AiChatCanvasContextToggled(bool),
|
|
AiChatStreamChunk(String),
|
|
AiChatStreamFinished(Result<String, String>),
|
|
|
|
// ── AI Script Generator ─────────────────────────────
|
|
AiScriptPromptChanged(String),
|
|
AiScriptProviderChanged(String),
|
|
AiScriptModelChanged(String),
|
|
AiScriptUrlChanged(String),
|
|
AiScriptGenerate,
|
|
AiScriptRun,
|
|
AiScriptCopy,
|
|
AiScriptClear,
|
|
AiScriptResult(Result<String, String>),
|
|
|
|
// ── Clipboard ───────────────────────────────────────
|
|
CopyImage,
|
|
PasteImage,
|
|
CopyText(String),
|
|
PasteText,
|
|
ClipboardResult(Result<Option<String>, String>),
|
|
ImageCopied(Result<(), String>),
|
|
ImagePasted(Result<Option<(Vec<u8>, u32, u32)>, String>),
|
|
|
|
// ── File I/O (with rfd) ─────────────────────────────
|
|
OpenFileRfd,
|
|
SaveFileAs,
|
|
FileDialogClosed(Option<std::path::PathBuf>),
|
|
|
|
// ── Dock Layout ─────────────────────────────────────
|
|
PaneClicked(iced::widget::pane_grid::Pane),
|
|
PaneDragged(iced::widget::pane_grid::DragEvent),
|
|
PaneResized(iced::widget::pane_grid::ResizeEvent),
|
|
PaneClose(iced::widget::pane_grid::Pane),
|
|
PaneMaximize(iced::widget::pane_grid::Pane),
|
|
|
|
// ── File I/O ────────────────────────────────────────
|
|
NewDocument(u32, u32),
|
|
DocSwitch(usize),
|
|
OpenFile,
|
|
FileOpened(Result<std::path::PathBuf, String>),
|
|
SaveFile,
|
|
|
|
// ── Menu ─────────────────────────────────────────────
|
|
MenuOpen(usize),
|
|
MenuClose,
|
|
MenuAction(usize, usize), // (menu_index, item_index)
|
|
OpenRecentFile(usize), // index into recent_files
|
|
ClearRecentFiles,
|
|
|
|
// ── Window ───────────────────────────────────────────
|
|
WindowDrag,
|
|
WindowMinimize,
|
|
WindowMaximize,
|
|
WindowClose,
|
|
WindowId(iced::window::Id),
|
|
|
|
// ── Dock Profiles ───────────────────────────────────
|
|
DockProfileSave(String),
|
|
DockProfileLoad(String),
|
|
DockProfileRename(String, String),
|
|
DockProfileDelete(String),
|
|
DockProfileRenameStart(String),
|
|
DockProfileRenameConfirm,
|
|
DockProfileRenameCancel,
|
|
DockProfileNewNameChanged(String),
|
|
DockProfileRenameChanged(String),
|
|
DockProfileDialogShow,
|
|
DockProfileDialogHide,
|
|
|
|
// ── Misc ────────────────────────────────────────────
|
|
NoOp,
|
|
|
|
// ── Image Viewer ────────────────────────────────────
|
|
ViewerToggle,
|
|
ViewerNavigate(std::path::PathBuf),
|
|
ViewerSelectFile(std::path::PathBuf),
|
|
ViewerOpenFile(std::path::PathBuf),
|
|
ViewerRefresh,
|
|
ViewerFullscreen(bool),
|
|
ViewerPrev,
|
|
ViewerNext,
|
|
ViewerEnter,
|
|
}
|
|
|
|
/// Convert a FilterType enum to the engine's string filter ID.
|
|
fn filter_type_to_id(filter: FilterType) -> &'static str {
|
|
match filter {
|
|
FilterType::BoxBlur => "box_blur",
|
|
FilterType::GaussianBlur => "gaussian_blur",
|
|
FilterType::MotionBlur => "motion_blur",
|
|
FilterType::UnsharpMask => "sharpen",
|
|
FilterType::Mosaic => "mosaic",
|
|
FilterType::Pinch => "pinch",
|
|
FilterType::Twirl => "twirl",
|
|
FilterType::OilPaint => "oil_paint",
|
|
FilterType::Crystallize => "crystallize",
|
|
FilterType::Levels => "levels",
|
|
FilterType::Vibrance => "vibrance",
|
|
FilterType::Exposure => "exposure",
|
|
FilterType::Posterize => "posterize",
|
|
FilterType::Threshold => "threshold",
|
|
FilterType::ChannelMixer => "channel_mixer",
|
|
FilterType::BlackAndWhite => "black_and_white",
|
|
FilterType::SelectiveColor => "selective_color",
|
|
FilterType::GammaCorrection => "gamma_correction",
|
|
FilterType::ExtractChannel => "extract_channel",
|
|
FilterType::GradientMap => "gradient_map",
|
|
FilterType::GaussianBlurGamma => "gaussian_blur_gamma",
|
|
FilterType::BoxBlurGamma => "box_blur_gamma",
|
|
FilterType::MedianFilter => "median_filter",
|
|
FilterType::Dehaze => "dehaze",
|
|
FilterType::NoisePattern => "noise_pattern",
|
|
}
|
|
}
|
|
|
|
/// Build a BrushTip from the current tool state.
|
|
///
|
|
/// Converts the tool state's brush parameters into an engine BrushTip
|
|
/// that can be passed to `engine.set_brush_tip()` before starting a stroke.
|
|
fn build_brush_tip(state: &ToolState) -> BrushTip {
|
|
BrushTip {
|
|
style: state.brush_style,
|
|
size: state.brush_size,
|
|
opacity: state.brush_opacity,
|
|
hardness: state.brush_hardness,
|
|
spacing: state.brush_spacing,
|
|
..BrushTip::default()
|
|
}
|
|
}
|
|
|
|
/// Convert a display label back to a PaneType for dock profile loading.
|
|
fn pane_type_from_label(label: &str) -> Option<crate::dock::state::PaneType> {
|
|
use crate::dock::state::PaneType;
|
|
match label {
|
|
"Canvas" => Some(PaneType::Canvas),
|
|
"Layers" => Some(PaneType::Layers),
|
|
"History" => Some(PaneType::History),
|
|
"Brushes & Tips" => Some(PaneType::Brushes),
|
|
"Filters" => Some(PaneType::Filters),
|
|
"Color Palette" => Some(PaneType::ColorPicker),
|
|
"Properties" => Some(PaneType::Properties),
|
|
"Script" => Some(PaneType::Script),
|
|
"AI Assistant" => Some(PaneType::AiChat),
|
|
"Layer Details" => Some(PaneType::LayerDetails),
|
|
"Geometry" => Some(PaneType::Geometry),
|
|
"Tool Settings" => Some(PaneType::ToolSettings),
|
|
"AI Script Generator" => Some(PaneType::AiScript),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Helper to combine two bounding boxes of dirty regions.
|
|
fn union_regions(r1: Option<[u32; 4]>, r2: [u32; 4]) -> [u32; 4] {
|
|
match r1 {
|
|
Some(r) => [
|
|
r[0].min(r2[0]),
|
|
r[1].min(r2[1]),
|
|
r[2].max(r2[2]),
|
|
r[3].max(r2[3]),
|
|
],
|
|
None => r2,
|
|
}
|
|
}
|
|
|
|
impl HcieIcedApp {
|
|
/// Create the initial application state.
|
|
///
|
|
/// If `load_path` is provided, the file will be opened after initialization.
|
|
/// Returns a Task to capture the window ID on startup.
|
|
pub fn new(load_path: Option<std::path::PathBuf>) -> (Self, Task<Message>) {
|
|
let mut engine = Engine::new(800, 600);
|
|
engine.pre_tile_all_layers();
|
|
let composite_raw = engine.get_composite_pixels();
|
|
|
|
let cached_layers = engine.layer_infos();
|
|
let history_len = engine.history_len();
|
|
let cached_history: Vec<(usize, String)> = (0..history_len)
|
|
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
|
.collect();
|
|
let history_current = engine.history_current();
|
|
|
|
let doc = IcedDocument {
|
|
engine,
|
|
composite_pixels: Arc::new(composite_raw.clone()),
|
|
composite_raw,
|
|
name: "Untitled".to_string(),
|
|
source_path: None,
|
|
modified: false,
|
|
zoom: 1.0,
|
|
pan_offset: Vector::ZERO,
|
|
cached_layers,
|
|
cached_history,
|
|
history_current,
|
|
selection_rect: None,
|
|
vector_draw: None,
|
|
pane_size: (800.0, 600.0),
|
|
render_generation: 0,
|
|
dirty_region: std::cell::RefCell::new(None),
|
|
full_upload: std::cell::RefCell::new(true),
|
|
selection_transform: None,
|
|
transform_drag_handle: TransformHandle::None,
|
|
transform_drag_start: None,
|
|
transform_original: None,
|
|
internal_clipboard: None,
|
|
selection_mask: None,
|
|
selection_bounds: None,
|
|
crop_state: CropState::default(),
|
|
};
|
|
|
|
// Load saved settings
|
|
let mut settings = crate::settings::AppSettings::load();
|
|
|
|
let mut app = Self {
|
|
documents: vec![doc],
|
|
active_doc: 0,
|
|
tool_state: ToolState::default(),
|
|
fg_color: [220, 50, 50, 255],
|
|
bg_color: [255, 255, 255, 255],
|
|
last_cursor_pos: None,
|
|
canvas_cursor_pos: None,
|
|
active_dialog: ActiveDialog::None,
|
|
active_tool_slot: 0,
|
|
sub_tools_open: false,
|
|
settings: settings.clone(),
|
|
window_id: None,
|
|
dialog_new_name: "Untitled".to_string(),
|
|
dialog_new_width: 800,
|
|
dialog_new_height: 600,
|
|
dialog_new_transparent: true,
|
|
dialog_brightness: 0.0,
|
|
dialog_contrast: 0.0,
|
|
dialog_hue: 0.0,
|
|
dialog_saturation: 0.0,
|
|
dialog_lightness: 0.0,
|
|
dialog_selection_value: 5.0,
|
|
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,
|
|
dock: DockState::new(),
|
|
tablet_state: Arc::new(Mutex::new(TabletState::new())),
|
|
initialized: false,
|
|
theme_state: ThemeState::new(),
|
|
active_menu: None,
|
|
recent_files: load_recent_files(),
|
|
recent_colors: Vec::new(),
|
|
selected_style: 0,
|
|
show_style_color_picker: false,
|
|
style_color_picker_idx: 0,
|
|
style_color_hsl: (0.0, 0.0, 0.0),
|
|
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(),
|
|
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_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(),
|
|
};
|
|
|
|
// Apply saved settings to tool state
|
|
settings.apply_to_tool_state(&mut app.tool_state);
|
|
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);
|
|
// Get the composite via the safe Vec-returning path.
|
|
// This calls get_composite_pixels() which internally uses
|
|
// composite_layers() — a pure-Rust function that returns Vec<u8>.
|
|
// This is safe and avoids the raw-pointer segfault in
|
|
// render_composite_region.
|
|
let composite = app.documents[0].engine.get_composite_pixels();
|
|
let size = composite.len();
|
|
log::info!("[startup] composite pixels: {} bytes ({}x{})", size,
|
|
app.documents[0].engine.canvas_width(), app.documents[0].engine.canvas_height());
|
|
app.documents[0].composite_raw = composite;
|
|
app.documents[0].composite_pixels = std::sync::Arc::new(app.documents[0].composite_raw.clone());
|
|
app.documents[0].full_upload.replace(true);
|
|
app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1);
|
|
// Refresh cached layer list and history for panels
|
|
app.documents[0].cached_layers = app.documents[0].engine.layer_infos();
|
|
let history_len = app.documents[0].engine.history_len();
|
|
app.documents[0].cached_history = (0..history_len)
|
|
.filter_map(|i| app.documents[0].engine.history_description(i).map(|d| (i, d)))
|
|
.collect();
|
|
app.documents[0].history_current = app.documents[0].engine.history_current();
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to open file on startup: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
app.initialized = true;
|
|
let init_task = iced::window::get_oldest().map(|opt_id| {
|
|
if let Some(id) = opt_id {
|
|
Message::WindowId(id)
|
|
} else {
|
|
Message::NoOp
|
|
}
|
|
});
|
|
(app, init_task)
|
|
}
|
|
|
|
/// Return a mutable reference to the active document.
|
|
fn active_document_mut(&mut self) -> &mut IcedDocument {
|
|
self.documents.get_mut(self.active_doc).expect("no active document")
|
|
}
|
|
|
|
/// Convert pane-relative coordinates to canvas-space coordinates.
|
|
///
|
|
/// The canvas image is centered in the pane, with `pan_offset` as an
|
|
/// additional offset from center. This function accounts for both.
|
|
fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
|
|
let doc = &self.documents[self.active_doc];
|
|
let zoom = doc.zoom;
|
|
let (pane_w, pane_h) = doc.pane_size;
|
|
let engine_w = doc.engine.canvas_width() as f32;
|
|
let engine_h = doc.engine.canvas_height() as f32;
|
|
let display_w = engine_w * zoom;
|
|
let display_h = engine_h * zoom;
|
|
|
|
// Canvas image position = center of pane + pan_offset
|
|
let canvas_origin_x = (pane_w - display_w) / 2.0 + doc.pan_offset.x;
|
|
let canvas_origin_y = (pane_h - display_h) / 2.0 + doc.pan_offset.y;
|
|
|
|
// Convert from pane-space to canvas-space
|
|
let canvas_x = (sx - canvas_origin_x) / zoom;
|
|
let canvas_y = (sy - canvas_origin_y) / zoom;
|
|
|
|
if canvas_x >= 0.0 && canvas_x < engine_w
|
|
&& canvas_y >= 0.0 && canvas_y < engine_h
|
|
{
|
|
Some((canvas_x, canvas_y))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Add a file to the recent files list (most recent first, max 20) and persist to disk.
|
|
fn add_recent_file(&mut self, path: &std::path::Path) {
|
|
let path_str = path.to_string_lossy().to_string();
|
|
let name = path.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| path_str.clone());
|
|
// Remove duplicates
|
|
self.recent_files.retain(|e| e.path != path_str);
|
|
// Insert at front
|
|
self.recent_files.insert(0, RecentFileEntry { path: path_str, name });
|
|
// Cap at 20
|
|
self.recent_files.truncate(20);
|
|
// Persist to disk
|
|
save_recent_files(&self.recent_files);
|
|
}
|
|
|
|
/// Add a color to the recent colors list (most recent first, max 40).
|
|
fn add_recent_color(&mut self, color: [u8; 4]) {
|
|
self.recent_colors.retain(|c| *c != color);
|
|
self.recent_colors.insert(0, color);
|
|
self.recent_colors.truncate(40);
|
|
}
|
|
|
|
/// Refresh the composite buffer using incremental dirty-region compositing.
|
|
///
|
|
/// Uses `render_composite_region()` which only re-composites dirty tiles.
|
|
/// Only the dirty region is copied from the engine's scratch buffer into
|
|
/// the local composite buffer, avoiding a full 33 MB copy for small strokes.
|
|
fn refresh_composite_if_needed(&mut self) {
|
|
let doc = &mut self.documents[self.active_doc];
|
|
|
|
if doc.engine.is_composite_dirty() {
|
|
// Use get_composite_pixels() instead of render_composite_region()
|
|
// to avoid segfaults when canvas dimensions change (e.g. after PSD import).
|
|
// get_composite_pixels() returns a safe Vec<u8> via composite_layers().
|
|
let composite = doc.engine.get_composite_pixels();
|
|
let size = composite.len();
|
|
if size > 0 {
|
|
doc.composite_raw = composite;
|
|
// Update the shared Arc — if no other reference exists, mutate in-place
|
|
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
|
|
pixels.resize(doc.composite_raw.len(), 0);
|
|
pixels.copy_from_slice(&doc.composite_raw);
|
|
} else {
|
|
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
|
|
}
|
|
doc.full_upload.replace(true);
|
|
doc.dirty_region.replace(None);
|
|
doc.render_generation = doc.render_generation.wrapping_add(1);
|
|
}
|
|
|
|
doc.engine.clear_dirty_flags();
|
|
}
|
|
|
|
// Always refresh cached panel data (cheap operation)
|
|
doc.cached_layers = doc.engine.layer_infos();
|
|
let history_len = doc.engine.history_len();
|
|
doc.cached_history = (0..history_len)
|
|
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
|
|
.collect();
|
|
doc.history_current = doc.engine.history_current();
|
|
}
|
|
|
|
/// Apply brush parameters from tool state to the engine.
|
|
///
|
|
/// Called before starting a stroke to ensure the engine uses
|
|
/// the current brush size, opacity, hardness, and style.
|
|
fn apply_brush_params(&mut self) {
|
|
let tip = build_brush_tip(&self.tool_state);
|
|
self.documents[self.active_doc].engine.set_brush_tip(tip);
|
|
}
|
|
|
|
// ── Update ──────────────────────────────────────────
|
|
|
|
/// Handle a message and return an optional command.
|
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
|
let frame_start = std::time::Instant::now();
|
|
let since_last = frame_start.duration_since(self.tool_state.last_update_instant);
|
|
self.tool_state.last_update_instant = frame_start;
|
|
|
|
// Update marching ants animation offset (cycles at ~60fps for smooth animation)
|
|
// The offset cycles from 0.0 to 1.0, completing one cycle every ~1 second
|
|
if self.documents.iter().any(|doc| doc.selection_bounds.is_some()) {
|
|
self.marching_ants_offset = (self.marching_ants_offset + since_last.as_secs_f32() * 0.5) % 1.0;
|
|
}
|
|
|
|
match message {
|
|
Message::ToolSelected(tool) => {
|
|
log::info!("Tool selected: {:?}", tool);
|
|
self.tool_state.active_tool = tool;
|
|
self.sub_tools_open = false;
|
|
self.settings.update_from_tool_state(&self.tool_state);
|
|
let _ = self.settings.save();
|
|
}
|
|
|
|
Message::ToolSlotClicked(slot_idx, primary_tool) => {
|
|
// If clicking the same slot that's already active, toggle sub-tools popup
|
|
if self.active_tool_slot == slot_idx && self.sub_tools_open {
|
|
self.sub_tools_open = false;
|
|
} else if self.active_tool_slot == slot_idx {
|
|
// Same slot, but popup not open — open it if slot has sub-tools
|
|
self.sub_tools_open = true;
|
|
} else {
|
|
// Different slot — select primary tool and close popup
|
|
self.active_tool_slot = slot_idx;
|
|
self.tool_state.active_tool = primary_tool;
|
|
self.sub_tools_open = false;
|
|
}
|
|
}
|
|
|
|
Message::FgColorChanged(color) => {
|
|
self.fg_color = color;
|
|
self.active_document_mut().engine.set_color(color);
|
|
}
|
|
|
|
Message::BgColorChanged(color) => {
|
|
self.bg_color = color;
|
|
}
|
|
|
|
Message::SwapColors => {
|
|
std::mem::swap(&mut self.fg_color, &mut self.bg_color);
|
|
let color = self.fg_color;
|
|
self.active_document_mut().engine.set_color(color);
|
|
}
|
|
Message::ColorTabChanged(tab) => {
|
|
self.color_tab = tab;
|
|
}
|
|
|
|
Message::CanvasPointerPressed { x, y } => {
|
|
// Coordinates are already in canvas-space from the canvas widget
|
|
let canvas_x = x;
|
|
let canvas_y = y;
|
|
self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
|
|
let tool = self.tool_state.active_tool;
|
|
|
|
match tool {
|
|
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
|
// Apply brush parameters before starting stroke
|
|
self.apply_brush_params();
|
|
|
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
|
self.documents[self.active_doc].engine.set_tool(tool);
|
|
self.documents[self.active_doc].engine.set_eraser(tool == Tool::Eraser);
|
|
self.documents[self.active_doc].engine.begin_stroke(layer_id, canvas_x, canvas_y);
|
|
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, 1.0);
|
|
self.tool_state.is_drawing = true;
|
|
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
|
|
self.tool_state.brush_accumulated_dist = 0.0;
|
|
self.tool_state.last_composite_refresh = std::time::Instant::now();
|
|
self.refresh_composite_if_needed();
|
|
}
|
|
Tool::Eyedropper => {
|
|
let cx = canvas_x as u32;
|
|
let cy = canvas_y as u32;
|
|
let w = self.documents[self.active_doc].engine.canvas_width();
|
|
let h = self.documents[self.active_doc].engine.canvas_height();
|
|
if cx < w && cy < h {
|
|
let pixels = &self.documents[self.active_doc].composite_raw;
|
|
let idx = ((cy * w + cx) * 4) as usize;
|
|
if idx + 3 < pixels.len() {
|
|
let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
|
|
self.fg_color = color;
|
|
self.documents[self.active_doc].engine.set_color(color);
|
|
}
|
|
}
|
|
}
|
|
Tool::FloodFill => {
|
|
let cx = canvas_x as u32;
|
|
let cy = canvas_y as u32;
|
|
let color = self.fg_color;
|
|
self.documents[self.active_doc].engine.flood_fill(cx, cy, color, 32);
|
|
self.refresh_composite_if_needed();
|
|
}
|
|
Tool::Select => {
|
|
// Start selection rectangle drag
|
|
let sx = canvas_x;
|
|
let sy = canvas_y;
|
|
return Task::perform(async move {}, move |_| {
|
|
Message::SelectionDragStart(sx, sy)
|
|
});
|
|
}
|
|
Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine => {
|
|
// Start vector shape drawing
|
|
let sx = canvas_x;
|
|
let sy = canvas_y;
|
|
return Task::perform(async move {}, move |_| {
|
|
Message::VectorDrawStart(sx, sy)
|
|
});
|
|
}
|
|
Tool::Crop => {
|
|
// Start crop drag
|
|
let sx = canvas_x;
|
|
let sy = canvas_y;
|
|
return Task::perform(async move {}, move |_| {
|
|
Message::CropDragStart(sx, sy)
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Message::CanvasPointerMoved { x, y } => {
|
|
// Coordinates are already in canvas-space from the canvas widget
|
|
self.canvas_cursor_pos = Some((x as u32, y as u32));
|
|
|
|
if self.tool_state.is_drawing {
|
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
|
|
|
let spacing: f32 = match self.tool_state.active_tool {
|
|
Tool::Spray => 2.0_f32,
|
|
_ => 1.0_f32,
|
|
}.max(1.0);
|
|
|
|
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
|
|
let dx = x - last_x;
|
|
let dy = y - last_y;
|
|
let dist = (dx * dx + dy * dy).sqrt();
|
|
|
|
// Use constant pressure for mouse input
|
|
// Real tablet pressure comes from tablet_state
|
|
let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
|
|
|
|
if dist >= spacing {
|
|
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure);
|
|
self.tool_state.last_stroke_pos = Some((x, y));
|
|
|
|
// Throttle composite refresh to ~60 FPS during strokes.
|
|
let now = std::time::Instant::now();
|
|
let elapsed = now.duration_since(self.tool_state.last_composite_refresh);
|
|
if elapsed.as_secs_f32() >= 0.016 {
|
|
self.tool_state.last_composite_refresh = now;
|
|
// Performance log for measuring drawing throttle frequency (should fire roughly every 16ms / 60 FPS).
|
|
// log::info!("[perf] throttle fire: {:.1}ms since last refresh", elapsed.as_secs_f64() * 1000.0);
|
|
self.refresh_composite_if_needed();
|
|
}
|
|
}
|
|
} else {
|
|
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0);
|
|
self.tool_state.last_stroke_pos = Some((x, y));
|
|
}
|
|
}
|
|
|
|
// Handle selection drag
|
|
if self.tool_state.active_tool == Tool::Select {
|
|
let mx = x;
|
|
let my = y;
|
|
return Task::perform(async move {}, move |_| {
|
|
Message::SelectionDragMove(mx, my)
|
|
});
|
|
}
|
|
|
|
// Handle vector draw drag
|
|
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
|
|
let mx = x;
|
|
let my = y;
|
|
return Task::perform(async move {}, move |_| {
|
|
Message::VectorDrawMove(mx, my)
|
|
});
|
|
}
|
|
|
|
// 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)
|
|
});
|
|
}
|
|
}
|
|
|
|
Message::CanvasPointerReleased => {
|
|
if self.tool_state.is_drawing {
|
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
|
self.documents[self.active_doc].engine.end_stroke(layer_id);
|
|
self.documents[self.active_doc].engine.commit_pending_history();
|
|
|
|
self.tool_state.is_drawing = false;
|
|
self.tool_state.last_stroke_pos = None;
|
|
self.documents[self.active_doc].modified = true;
|
|
|
|
self.refresh_composite_if_needed();
|
|
}
|
|
|
|
// End selection drag
|
|
if self.tool_state.active_tool == Tool::Select {
|
|
return Task::perform(async {}, |_| Message::SelectionDragEnd);
|
|
}
|
|
|
|
// End vector draw
|
|
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
|
|
return Task::perform(async {}, |_| Message::VectorDrawEnd);
|
|
}
|
|
|
|
// End crop drag
|
|
if self.tool_state.active_tool == Tool::Crop {
|
|
return Task::perform(async {}, |_| Message::CropDragEnd);
|
|
}
|
|
}
|
|
|
|
Message::CanvasPanZoom { zoom, pan_offset } => {
|
|
let doc = &mut self.documents[self.active_doc];
|
|
doc.zoom = zoom;
|
|
doc.pan_offset = pan_offset;
|
|
}
|
|
|
|
Message::CanvasCursorPos { x, y } => {
|
|
self.canvas_cursor_pos = Some((x, y));
|
|
}
|
|
|
|
Message::CanvasZoomSet(z) => {
|
|
let doc = &mut self.documents[self.active_doc];
|
|
doc.zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
|
|
// Keep pan_offset stable so the visible content does not jump
|
|
// when zooming via keyboard/menu/slider. The canvas view
|
|
// centers on pane+pan_offset, so holding pan_offset keeps the
|
|
// same logical content anchored.
|
|
}
|
|
|
|
Message::CanvasZoomRelative(factor) => {
|
|
let doc = &mut self.documents[self.active_doc];
|
|
doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
|
// Keep pan_offset — zooming relative to center via keyboard.
|
|
}
|
|
|
|
Message::CanvasSize((w, h)) => {
|
|
self.documents[self.active_doc].pane_size = (w, h);
|
|
// Keep pan_offset so the canvas stays anchored when the pane
|
|
// is resized (e.g. dock splitter dragged).
|
|
}
|
|
|
|
Message::CompositeRefresh | Message::Undo | Message::Redo => {
|
|
match message {
|
|
Message::Undo => { self.documents[self.active_doc].engine.undo(); }
|
|
Message::Redo => { self.documents[self.active_doc].engine.redo(); }
|
|
_ => {}
|
|
}
|
|
self.refresh_composite_if_needed();
|
|
}
|
|
|
|
// ── Layer operations ──────────────────────────
|
|
Message::LayerSelect(id) => {
|
|
self.documents[self.active_doc].engine.set_active_layer(id);
|
|
}
|
|
|
|
Message::LayerAdd => {
|
|
let count = self.documents[self.active_doc].engine.get_layer_count();
|
|
self.documents[self.active_doc].engine.add_layer(&format!("Layer {}", count + 1));
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
Message::LayerDelete(id) => {
|
|
self.documents[self.active_doc].engine.delete_layer(id);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
Message::LayerToggleVisibility(id) => {
|
|
let visible = self.documents[self.active_doc].engine.get_layer_info(id)
|
|
.map(|l| !l.visible)
|
|
.unwrap_or(true);
|
|
self.documents[self.active_doc].engine.set_layer_visible(id, visible);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
Message::LayerToggleLock(id) => {
|
|
let locked = self.documents[self.active_doc].engine.get_layer_info(id)
|
|
.map(|l| !l.locked)
|
|
.unwrap_or(false);
|
|
self.documents[self.active_doc].engine.set_layer_locked(id, locked);
|
|
}
|
|
|
|
Message::LayerSetOpacity(id, opacity) => {
|
|
self.documents[self.active_doc].engine.set_layer_opacity(id, opacity);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
Message::LayerMergeDown => {
|
|
self.documents[self.active_doc].engine.merge_down();
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
Message::LayerFlatten => {
|
|
self.documents[self.active_doc].engine.flatten_image();
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
Message::LayerMoveUp(id) => {
|
|
let infos = self.documents[self.active_doc].engine.layer_infos();
|
|
if let Some(pos) = infos.iter().position(|l| l.id == id) {
|
|
if pos > 0 {
|
|
let target_idx = pos - 1;
|
|
self.documents[self.active_doc].engine.move_layer(id, target_idx);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
}
|
|
|
|
Message::LayerMoveDown(id) => {
|
|
let infos = self.documents[self.active_doc].engine.layer_infos();
|
|
if let Some(pos) = infos.iter().position(|l| l.id == id) {
|
|
if pos + 1 < infos.len() {
|
|
let target_idx = pos + 1;
|
|
self.documents[self.active_doc].engine.move_layer(id, target_idx);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
}
|
|
|
|
Message::LayerSetBlendMode(id, mode) => {
|
|
self.documents[self.active_doc].engine.set_layer_blend_mode(id, mode);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
Message::LayerSetFillOpacity(_id, _opacity) => {
|
|
// Fill opacity is not yet exposed via engine API; placeholder for future use.
|
|
}
|
|
|
|
Message::LayerAddGroup => {
|
|
let count = self.documents[self.active_doc].engine.get_layer_count();
|
|
self.documents[self.active_doc].engine.add_group(&format!("Group {}", count + 1));
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
Message::LayerToggleCollapse(id) => {
|
|
self.documents[self.active_doc].engine.toggle_group_collapsed(id);
|
|
}
|
|
|
|
// ── History ───────────────────────────────────
|
|
Message::HistoryJumpTo(idx) => {
|
|
self.documents[self.active_doc].engine.jump_to_history(idx);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
// ── Text tool ─────────────────────────────────
|
|
Message::TextSizeChanged(_size) => {
|
|
// TODO: apply to active text tool config
|
|
}
|
|
Message::TextColorChanged(_color) => {
|
|
// TODO: apply to active text tool config
|
|
}
|
|
Message::TextAlignChanged(_alignment) => {
|
|
// TODO: apply to active text tool config
|
|
}
|
|
Message::TextAccept => {
|
|
// TODO: commit text changes
|
|
}
|
|
Message::TextCancel => {
|
|
// TODO: cancel text changes
|
|
}
|
|
|
|
// ── Layer styles ──────────────────────────────
|
|
Message::LayerStyleToggle(index) => {
|
|
// Toggle layer style enabled state.
|
|
// BUG FIX: When style doesn't exist, first click creates it ENABLED (not disabled).
|
|
// Previous logic used map_or(false, ...) which created disabled styles on first click.
|
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
|
if layer_id != 0 {
|
|
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
|
|
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
|
|
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
|
|
if (index as usize) < disc_names.len() {
|
|
let disc = disc_names[index as usize];
|
|
// Find existing style
|
|
let existing = styles.iter().find(|s| {
|
|
matches!((disc, s),
|
|
("DropShadow", LayerStyle::DropShadow { .. })
|
|
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
|
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
|
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
|
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
|
| ("Satin", LayerStyle::Satin { .. })
|
|
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
|
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
|
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
|
| ("Stroke", LayerStyle::Stroke { .. })
|
|
)
|
|
});
|
|
|
|
// Determine new style:
|
|
// - Style doesn't exist → create default style with enabled: true
|
|
// - Style exists → clone and toggle enabled flag
|
|
let new_style = match existing {
|
|
None => {
|
|
println!("LayerStyleToggle: Style {} not found, creating new", disc);
|
|
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;
|
|
println!("LayerStyleToggle: Style {} found, toggling to {}", disc, *enabled);
|
|
}
|
|
}
|
|
cloned
|
|
}
|
|
};
|
|
println!("LayerStyleToggle: Calling update_layer_style with new_style: {:?}", new_style);
|
|
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
|
|
}
|
|
}
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Message::LayerStyleSelect(index) => {
|
|
self.selected_style = index;
|
|
}
|
|
Message::LayerStyleApply => {
|
|
self.active_dialog = ActiveDialog::None;
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Message::LayerStyleCancel => {
|
|
self.active_dialog = ActiveDialog::None;
|
|
}
|
|
Message::LayerStyleAdd => {
|
|
// Open layer style add dialog (placeholder)
|
|
}
|
|
Message::LayerStyleUpdateParam(index, param, value) => {
|
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
|
if layer_id != 0 {
|
|
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
|
|
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
|
|
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
|
|
if index < disc_names.len() {
|
|
let disc = disc_names[index];
|
|
let existing = styles.iter().find(|s| {
|
|
matches!((disc, s),
|
|
("DropShadow", LayerStyle::DropShadow { .. })
|
|
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
|
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
|
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
|
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
|
| ("Satin", LayerStyle::Satin { .. })
|
|
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
|
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
|
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
|
| ("Stroke", LayerStyle::Stroke { .. })
|
|
)
|
|
});
|
|
|
|
if let Some(style) = existing {
|
|
let mut new_style = style.clone();
|
|
// Update the specific parameter
|
|
match (&mut new_style, param.as_str()) {
|
|
(LayerStyle::DropShadow { opacity, .. }, "opacity") => *opacity = value,
|
|
(LayerStyle::DropShadow { angle, .. }, "angle") => *angle = value,
|
|
(LayerStyle::DropShadow { distance, .. }, "distance") => *distance = value,
|
|
(LayerStyle::DropShadow { spread, .. }, "spread") => *spread = value,
|
|
(LayerStyle::DropShadow { size, .. }, "size") => *size = value,
|
|
(LayerStyle::InnerShadow { opacity, .. }, "opacity") => *opacity = value,
|
|
(LayerStyle::InnerShadow { angle, .. }, "angle") => *angle = value,
|
|
(LayerStyle::InnerShadow { distance, .. }, "distance") => *distance = value,
|
|
(LayerStyle::InnerShadow { spread, .. }, "spread") => *spread = value,
|
|
(LayerStyle::InnerShadow { size, .. }, "size") => *size = value,
|
|
(LayerStyle::OuterGlow { opacity, .. }, "opacity") => *opacity = value,
|
|
(LayerStyle::OuterGlow { spread, .. }, "spread") => *spread = value,
|
|
(LayerStyle::OuterGlow { size, .. }, "size") => *size = value,
|
|
(LayerStyle::InnerGlow { opacity, .. }, "opacity") => *opacity = value,
|
|
(LayerStyle::InnerGlow { spread, .. }, "spread") => *spread = value,
|
|
(LayerStyle::InnerGlow { size, .. }, "size") => *size = value,
|
|
(LayerStyle::BevelEmboss { depth, .. }, "depth") => *depth = value,
|
|
(LayerStyle::BevelEmboss { size, .. }, "size") => *size = value,
|
|
(LayerStyle::BevelEmboss { angle, .. }, "angle") => *angle = value,
|
|
(LayerStyle::BevelEmboss { altitude, .. }, "altitude") => *altitude = value,
|
|
(LayerStyle::BevelEmboss { soften, .. }, "soften") => *soften = value,
|
|
(LayerStyle::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) => {
|
|
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) => {
|
|
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) => {
|
|
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) => {
|
|
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) => {
|
|
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) => {
|
|
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 => {
|
|
self.active_dialog = ActiveDialog::LayerStyleDialog;
|
|
self.layer_style_offset = (0.0, 0.0);
|
|
self.layer_style_dragging = false;
|
|
self.layer_style_drag_start = None;
|
|
}
|
|
Message::LayerStyleDialogDragStart(_x, _y) => {
|
|
// Drag started — begin tracking mouse movement.
|
|
// The actual offset computation uses delta from the last position.
|
|
self.layer_style_dragging = true;
|
|
}
|
|
Message::LayerStyleDialogDragMove(x, y) => {
|
|
if self.layer_style_dragging {
|
|
if let Some((lx, ly)) = self.layer_style_drag_start {
|
|
// Compute delta from last position and add to offset
|
|
let dx = x - lx;
|
|
let dy = y - ly;
|
|
self.layer_style_offset.0 += dx;
|
|
self.layer_style_offset.1 += dy;
|
|
}
|
|
self.layer_style_drag_start = Some((x, y));
|
|
}
|
|
}
|
|
Message::LayerStyleDialogDragEnd => {
|
|
self.layer_style_dragging = false;
|
|
self.layer_style_drag_start = None;
|
|
}
|
|
|
|
// ── Brushes ───────────────────────────────────
|
|
Message::BrushStyleSelected(style) => {
|
|
log::info!("Brush style selected: {:?}", style);
|
|
self.tool_state.brush_style = style;
|
|
}
|
|
Message::BrushSizeChanged(size) => {
|
|
self.tool_state.brush_size = size;
|
|
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::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.selected_filter = Some(filter);
|
|
self.filter_preview_active = true;
|
|
self.filter_params = serde_json::json!({});
|
|
self.documents[self.active_doc].engine.filter_preview_begin();
|
|
}
|
|
Message::FilterParamChanged(key, value) => {
|
|
// Update the specific parameter in filter_params
|
|
if let serde_json::Value::Object(ref mut map) = self.filter_params {
|
|
map.insert(key, value);
|
|
}
|
|
// Apply preview with updated params
|
|
if let Some(filter) = self.selected_filter {
|
|
let filter_id = filter_type_to_id(filter);
|
|
self.documents[self.active_doc].engine.apply_filter_preview(filter_id, self.filter_params.clone());
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
Message::FilterApply => {
|
|
if let Some(filter) = self.selected_filter {
|
|
let filter_id = filter_type_to_id(filter);
|
|
self.documents[self.active_doc].engine.apply_filter(filter_id, self.filter_params.clone());
|
|
self.refresh_composite_if_needed();
|
|
self.filter_preview_active = false;
|
|
self.selected_filter = None;
|
|
self.filter_params = serde_json::json!({});
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
Message::FilterReset => {
|
|
if self.filter_preview_active {
|
|
self.documents[self.active_doc].engine.filter_preview_restore();
|
|
self.refresh_composite_if_needed();
|
|
self.filter_preview_active = false;
|
|
}
|
|
self.selected_filter = None;
|
|
self.filter_params = serde_json::json!({});
|
|
}
|
|
|
|
Message::FilterPreviewToggle(enabled) => {
|
|
self.filter_preview_active = enabled;
|
|
if enabled {
|
|
// Start a new preview snapshot so restore can undo it
|
|
self.documents[self.active_doc].engine.filter_preview_begin();
|
|
// Apply the current filter with current params to show live preview
|
|
if let Some(filter) = self.selected_filter {
|
|
let filter_id = filter_type_to_id(filter);
|
|
self.documents[self.active_doc].engine.apply_filter_preview(filter_id, self.filter_params.clone());
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
} else {
|
|
// Restore original pixels
|
|
self.documents[self.active_doc].engine.filter_preview_restore();
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
|
|
Message::FilterPreviewApply => {
|
|
if let Some(filter) = self.selected_filter {
|
|
let filter_id = filter_type_to_id(filter);
|
|
self.documents[self.active_doc].engine.apply_filter(filter_id, self.filter_params.clone());
|
|
self.refresh_composite_if_needed();
|
|
self.filter_preview_active = false;
|
|
self.selected_filter = None;
|
|
self.filter_params = serde_json::json!({});
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
|
|
Message::FilterPreviewRestore => {
|
|
if self.filter_preview_active {
|
|
self.documents[self.active_doc].engine.filter_preview_restore();
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
|
|
// ── Dialogs ───────────────────────────────────
|
|
Message::DialogOpen(dialog) => {
|
|
self.active_dialog = dialog;
|
|
}
|
|
Message::DialogClose => {
|
|
self.active_dialog = ActiveDialog::None;
|
|
}
|
|
Message::DialogCloseSave => {
|
|
// Save the current document then close
|
|
let doc = &self.documents[self.active_doc];
|
|
if let Some(path) = &doc.source_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" => self.documents[self.active_doc].engine.export_psd(&path_str),
|
|
"kra" => self.documents[self.active_doc].engine.export_kra(&path_str),
|
|
"hcie" => self.documents[self.active_doc].engine.save_native(&path_str),
|
|
_ => self.documents[self.active_doc].engine.save_as(&path_str, &ext),
|
|
};
|
|
if result.is_ok() {
|
|
self.documents[self.active_doc].modified = false;
|
|
}
|
|
}
|
|
self.active_dialog = ActiveDialog::None;
|
|
if let Some(id) = self.window_id {
|
|
return iced::window::close(id);
|
|
}
|
|
}
|
|
Message::DialogCloseDiscard => {
|
|
// Discard changes and close
|
|
self.documents[self.active_doc].modified = false;
|
|
self.active_dialog = ActiveDialog::None;
|
|
if let Some(id) = self.window_id {
|
|
return iced::window::close(id);
|
|
}
|
|
}
|
|
|
|
// ── New Image Dialog ──────────────────────────
|
|
Message::DialogNewImageName(name) => {
|
|
self.dialog_new_name = name;
|
|
}
|
|
Message::DialogNewImageWidth(w) => {
|
|
self.dialog_new_width = w;
|
|
}
|
|
Message::DialogNewImageHeight(h) => {
|
|
self.dialog_new_height = h;
|
|
}
|
|
Message::DialogNewImageTransparent(t) => {
|
|
self.dialog_new_transparent = t;
|
|
}
|
|
Message::DialogNewImagePreset(w, h) => {
|
|
self.dialog_new_width = w;
|
|
self.dialog_new_height = h;
|
|
}
|
|
Message::DialogNewImageCreate => {
|
|
let w = self.dialog_new_width;
|
|
let h = self.dialog_new_height;
|
|
let name = self.dialog_new_name.clone();
|
|
let transparent = self.dialog_new_transparent;
|
|
let mut engine = Engine::new_with_options(&name, w, h, transparent);
|
|
engine.pre_tile_all_layers();
|
|
let composite_raw = engine.get_composite_pixels();
|
|
let cached_layers = engine.layer_infos();
|
|
let history_len = engine.history_len();
|
|
let cached_history: Vec<(usize, String)> = (0..history_len)
|
|
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
|
.collect();
|
|
let history_current = engine.history_current();
|
|
self.documents.push(IcedDocument {
|
|
engine,
|
|
composite_pixels: Arc::new(composite_raw.clone()),
|
|
composite_raw,
|
|
name,
|
|
source_path: None,
|
|
modified: false,
|
|
zoom: 1.0,
|
|
pan_offset: Vector::ZERO,
|
|
cached_layers,
|
|
cached_history,
|
|
history_current,
|
|
selection_rect: None,
|
|
vector_draw: None,
|
|
pane_size: (800.0, 600.0),
|
|
render_generation: 0,
|
|
dirty_region: std::cell::RefCell::new(None),
|
|
full_upload: std::cell::RefCell::new(true),
|
|
selection_transform: None,
|
|
transform_drag_handle: TransformHandle::None,
|
|
transform_drag_start: None,
|
|
transform_original: None,
|
|
internal_clipboard: None,
|
|
selection_mask: None,
|
|
selection_bounds: None,
|
|
crop_state: CropState::default(),
|
|
});
|
|
self.active_doc = self.documents.len() - 1;
|
|
self.active_dialog = ActiveDialog::None;
|
|
}
|
|
|
|
// ── Adjustments Dialog ────────────────────────
|
|
Message::AdjBrightness(v) => {
|
|
self.dialog_brightness = v;
|
|
let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast});
|
|
self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Message::AdjContrast(v) => {
|
|
self.dialog_contrast = v;
|
|
let params = serde_json::json!({"brightness": self.dialog_brightness, "contrast": v});
|
|
self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Message::AdjHue(v) => {
|
|
self.dialog_hue = v;
|
|
let params = serde_json::json!({"hue": v, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness});
|
|
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Message::AdjSaturation(v) => {
|
|
self.dialog_saturation = v;
|
|
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": v, "lightness": self.dialog_lightness});
|
|
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Message::AdjLightness(v) => {
|
|
self.dialog_lightness = v;
|
|
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": v});
|
|
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Message::AdjApply => {
|
|
// Apply the adjustment permanently (not just preview)
|
|
let params = match &self.active_dialog {
|
|
ActiveDialog::BrightnessContrast => {
|
|
serde_json::json!({"brightness": self.dialog_brightness, "contrast": self.dialog_contrast})
|
|
}
|
|
ActiveDialog::HueSaturation => {
|
|
serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness})
|
|
}
|
|
_ => serde_json::json!({}),
|
|
};
|
|
|
|
// First restore the preview, then apply permanently
|
|
self.documents[self.active_doc].engine.filter_preview_restore();
|
|
|
|
let filter_id = match &self.active_dialog {
|
|
ActiveDialog::BrightnessContrast => "brightness_contrast",
|
|
ActiveDialog::HueSaturation => "hue_saturation",
|
|
_ => "",
|
|
};
|
|
|
|
if !filter_id.is_empty() {
|
|
self.documents[self.active_doc].engine.apply_filter(filter_id, params);
|
|
}
|
|
|
|
self.refresh_composite_if_needed();
|
|
self.active_dialog = ActiveDialog::None;
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
// ── Selection Dialog ──────────────────────────
|
|
Message::SelectionOpValue(v) => {
|
|
self.dialog_selection_value = v;
|
|
}
|
|
Message::SelectionOpApply => {
|
|
let v = self.dialog_selection_value as u32;
|
|
match &self.active_dialog {
|
|
ActiveDialog::SelectionOp(op) => {
|
|
match *op {
|
|
"Grow" => self.documents[self.active_doc].engine.selection_grow(v),
|
|
"Shrink" => self.documents[self.active_doc].engine.selection_shrink(v),
|
|
"Feather" => self.documents[self.active_doc].engine.selection_feather(v as f32),
|
|
"Border" => self.documents[self.active_doc].engine.selection_border(v),
|
|
"Smooth" => self.documents[self.active_doc].engine.selection_smooth(v),
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
self.refresh_composite_if_needed();
|
|
self.active_dialog = ActiveDialog::None;
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
// ── Image Size Dialog ────────────────────────
|
|
Message::DialogImageSizeWidth(w) => {
|
|
self.dialog_image_size_width = w;
|
|
if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 {
|
|
self.dialog_image_size_height = (w as f32 / self.dialog_image_size_original_ratio) as u32;
|
|
}
|
|
}
|
|
Message::DialogImageSizeHeight(h) => {
|
|
self.dialog_image_size_height = h;
|
|
if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 {
|
|
self.dialog_image_size_width = (h as f32 * self.dialog_image_size_original_ratio) as u32;
|
|
}
|
|
}
|
|
Message::DialogImageSizeConstrain(c) => {
|
|
self.dialog_image_size_constrain = c;
|
|
}
|
|
Message::DialogImageSizeApply => {
|
|
let w = self.dialog_image_size_width.max(1);
|
|
let h = self.dialog_image_size_height.max(1);
|
|
self.documents[self.active_doc].engine.resize_canvas(w, h);
|
|
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
|
self.refresh_composite_if_needed();
|
|
self.documents[self.active_doc].full_upload.replace(true);
|
|
self.active_dialog = ActiveDialog::None;
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
// ── Canvas Size Dialog ───────────────────────
|
|
Message::DialogCanvasSizeWidth(w) => {
|
|
self.dialog_canvas_size_width = w;
|
|
}
|
|
Message::DialogCanvasSizeHeight(h) => {
|
|
self.dialog_canvas_size_height = h;
|
|
}
|
|
Message::DialogCanvasSizeAnchor(r, c) => {
|
|
self.dialog_canvas_size_anchor = (r, c);
|
|
}
|
|
Message::DialogCanvasSizeApply => {
|
|
let new_w = self.dialog_canvas_size_width.max(1);
|
|
let new_h = self.dialog_canvas_size_height.max(1);
|
|
self.documents[self.active_doc].engine.resize_canvas(new_w, new_h);
|
|
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
|
self.refresh_composite_if_needed();
|
|
self.documents[self.active_doc].full_upload.replace(true);
|
|
self.active_dialog = ActiveDialog::None;
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
// ── Selection tools ───────────────────────────
|
|
Message::SelectionDragStart(x, y) => {
|
|
self.documents[self.active_doc].selection_rect = Some((x, y, x, y));
|
|
}
|
|
Message::SelectionDragMove(x, y) => {
|
|
if let Some((x0, y0, _, _)) = self.documents[self.active_doc].selection_rect {
|
|
self.documents[self.active_doc].selection_rect = Some((x0, y0, x, y));
|
|
}
|
|
}
|
|
Message::SelectionDragEnd => {
|
|
if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect {
|
|
let sx = x0.min(x1) as u32;
|
|
let sy = y0.min(y1) as u32;
|
|
let ex = x0.max(x1) as u32;
|
|
let ey = y0.max(y1) as u32;
|
|
if ex > sx && ey > sy {
|
|
self.documents[self.active_doc].engine.create_selection_rect(sx, sy, ex, ey);
|
|
// Store selection bounds for marching ants display
|
|
self.documents[self.active_doc].selection_bounds = Some((sx, sy, ex - sx, ey - sy));
|
|
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 w = self.documents[self.active_doc].engine.canvas_width();
|
|
let h = self.documents[self.active_doc].engine.canvas_height();
|
|
self.documents[self.active_doc].engine.selection_all();
|
|
self.documents[self.active_doc].selection_bounds = Some((0, 0, w, h));
|
|
}
|
|
Message::Deselect => {
|
|
self.documents[self.active_doc].selection_rect = None;
|
|
self.documents[self.active_doc].selection_bounds = None;
|
|
self.documents[self.active_doc].selection_transform = None;
|
|
self.documents[self.active_doc].transform_drag_handle = TransformHandle::None;
|
|
self.documents[self.active_doc].engine.selection_clear();
|
|
}
|
|
Message::SelectInverse => {
|
|
self.documents[self.active_doc].engine.selection_invert();
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
// ── 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];
|
|
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 => {
|
|
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y + dy);
|
|
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y - dy);
|
|
doc.transform_drag_start = Some((x, y));
|
|
}
|
|
TransformHandle::TopRight => {
|
|
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y - dy);
|
|
tr.pos = iced::Vector::new(tr.pos.x, tr.pos.y + dy);
|
|
doc.transform_drag_start = Some((x, y));
|
|
}
|
|
TransformHandle::BottomLeft => {
|
|
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y + dy);
|
|
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y);
|
|
doc.transform_drag_start = Some((x, y));
|
|
}
|
|
TransformHandle::BottomRight => {
|
|
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y + dy);
|
|
doc.transform_drag_start = Some((x, y));
|
|
}
|
|
TransformHandle::Top => {
|
|
tr.pos = iced::Vector::new(tr.pos.x, tr.pos.y + dy);
|
|
tr.size = iced::Vector::new(tr.size.x, tr.size.y - dy);
|
|
doc.transform_drag_start = Some((x, y));
|
|
}
|
|
TransformHandle::Bottom => {
|
|
tr.size = iced::Vector::new(tr.size.x, tr.size.y + dy);
|
|
doc.transform_drag_start = Some((x, y));
|
|
}
|
|
TransformHandle::Left => {
|
|
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y);
|
|
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y);
|
|
doc.transform_drag_start = Some((x, y));
|
|
}
|
|
TransformHandle::Right => {
|
|
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y);
|
|
doc.transform_drag_start = Some((x, y));
|
|
}
|
|
TransformHandle::Rotate => {
|
|
// Calculate rotation from center
|
|
let center_x = tr.pos.x + tr.size.x / 2.0;
|
|
let center_y = tr.pos.y + tr.size.y / 2.0;
|
|
let angle = (y - center_y).atan2(x - center_x);
|
|
tr.rotation = angle;
|
|
}
|
|
TransformHandle::None => {}
|
|
}
|
|
}
|
|
}
|
|
Message::TransformDragEnd => {
|
|
let doc = &mut self.documents[self.active_doc];
|
|
doc.transform_drag_start = None;
|
|
doc.transform_original = None;
|
|
}
|
|
Message::TransformApply => {
|
|
let doc = &mut self.documents[self.active_doc];
|
|
if let Some(_tr) = doc.selection_transform.take() {
|
|
// TODO: Apply transform to layer pixels
|
|
// For now, just clear the transform
|
|
doc.transform_drag_handle = TransformHandle::None;
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
Message::TransformCancel => {
|
|
let doc = &mut self.documents[self.active_doc];
|
|
// Restore original transform if we have it
|
|
if let Some(original) = doc.transform_original.take() {
|
|
doc.selection_transform = Some(original);
|
|
} else {
|
|
doc.selection_transform = None;
|
|
}
|
|
doc.transform_drag_start = None;
|
|
doc.transform_drag_handle = TransformHandle::None;
|
|
}
|
|
|
|
// ── Vector tools ──────────────────────────────
|
|
Message::VectorDrawStart(x, y) => {
|
|
self.documents[self.active_doc].vector_draw = Some(((x, y), (x, y)));
|
|
}
|
|
Message::VectorDrawMove(x, y) => {
|
|
if let Some((start, _)) = self.documents[self.active_doc].vector_draw {
|
|
self.documents[self.active_doc].vector_draw = Some((start, (x, y)));
|
|
}
|
|
}
|
|
Message::VectorDrawEnd => {
|
|
if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].vector_draw.take() {
|
|
let engine = &mut self.documents[self.active_doc].engine;
|
|
let color = self.fg_color;
|
|
|
|
match self.tool_state.active_tool {
|
|
Tool::VectorRect => {
|
|
let shape = hcie_engine_api::VectorShape::Rect {
|
|
x1: x0.min(x1),
|
|
y1: y0.min(y1),
|
|
x2: x0.max(x1),
|
|
y2: y0.max(y1),
|
|
fill: true,
|
|
fill_color: color,
|
|
stroke: 2.0,
|
|
color,
|
|
radius: 0.0,
|
|
angle: 0.0,
|
|
opacity: 1.0,
|
|
hardness: 0.5,
|
|
};
|
|
engine.add_vector_shape(shape);
|
|
}
|
|
Tool::VectorCircle => {
|
|
let shape = hcie_engine_api::VectorShape::Circle {
|
|
x1: x0.min(x1),
|
|
y1: y0.min(y1),
|
|
x2: x0.max(x1),
|
|
y2: y0.max(y1),
|
|
fill: true,
|
|
fill_color: color,
|
|
stroke: 2.0,
|
|
color,
|
|
angle: 0.0,
|
|
opacity: 1.0,
|
|
hardness: 0.5,
|
|
};
|
|
engine.add_vector_shape(shape);
|
|
}
|
|
Tool::VectorLine => {
|
|
let shape = hcie_engine_api::VectorShape::Line {
|
|
x1: x0,
|
|
y1: y0,
|
|
x2: x1,
|
|
y2: y1,
|
|
stroke: 2.0,
|
|
color,
|
|
angle: 0.0,
|
|
cap_start: hcie_engine_api::LineCap::Round,
|
|
cap_end: hcie_engine_api::LineCap::Round,
|
|
opacity: 1.0,
|
|
hardness: 0.5,
|
|
};
|
|
engine.add_vector_shape(shape);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
self.refresh_composite_if_needed();
|
|
}
|
|
}
|
|
|
|
// ── Crop tool ────────────────────────────────
|
|
Message::CropDragStart(x, y) => {
|
|
self.documents[self.active_doc].crop_state.start_drag(x, y);
|
|
}
|
|
Message::CropDragMove(x, y) => {
|
|
self.documents[self.active_doc].crop_state.update_drag(x, y);
|
|
}
|
|
Message::CropDragEnd => {
|
|
self.documents[self.active_doc].crop_state.end_drag();
|
|
}
|
|
Message::CropConfirm => {
|
|
if let Some((x, y, w, h)) = self.documents[self.active_doc].crop_state.confirm() {
|
|
self.documents[self.active_doc].engine.crop(x, y, w, h);
|
|
self.documents[self.active_doc].crop_state.cancel();
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
}
|
|
Message::CropCancel => {
|
|
self.documents[self.active_doc].crop_state.cancel();
|
|
}
|
|
|
|
// ── Script Editor ──────────────────────────────
|
|
Message::ScriptEditorAction(action) => {
|
|
self.script_content.perform(action);
|
|
self.script_text = self.script_content.text();
|
|
}
|
|
Message::ScriptTextChanged(text) => {
|
|
self.script_text = text;
|
|
}
|
|
Message::ScriptRun => {
|
|
match crate::script::parser::parse_dsl_script(&self.script_text) {
|
|
Ok(actions) => {
|
|
self.script_error = None;
|
|
self.script_error_line = None;
|
|
self.script_is_running = true;
|
|
crate::script::executor::execute_actions(
|
|
&mut self.documents[self.active_doc].engine,
|
|
&actions,
|
|
);
|
|
self.script_is_running = false;
|
|
self.documents[self.active_doc].modified = true;
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Err(e) => {
|
|
self.script_error_line = crate::script::parser::parse_error_line(&e);
|
|
self.script_error = Some(e);
|
|
}
|
|
}
|
|
}
|
|
Message::ScriptClear => {
|
|
self.script_text.clear();
|
|
self.script_error = None;
|
|
self.script_error_line = None;
|
|
}
|
|
|
|
// ── AI Chat ───────────────────────────────────
|
|
Message::AiChatInput(text) => {
|
|
self.ai_chat_state.input = text;
|
|
}
|
|
Message::AiChatSend => {
|
|
let user_msg = self.ai_chat_state.input.trim().to_string();
|
|
if user_msg.is_empty() || self.ai_chat_state.is_streaming {
|
|
return Task::none();
|
|
}
|
|
|
|
// Add user message to history
|
|
self.ai_chat_state.messages.push(ChatMessage {
|
|
role: "user".to_string(),
|
|
content: user_msg.clone(),
|
|
is_reasoning: false,
|
|
});
|
|
self.ai_chat_state.input.clear();
|
|
self.ai_chat_state.is_streaming = true;
|
|
self.ai_chat_state.current_response.clear();
|
|
self.ai_chat_state.current_reasoning.clear();
|
|
|
|
// Build the final prompt with optional canvas context
|
|
let mut final_prompt = user_msg.clone();
|
|
if self.ai_chat_state.config.canvas_context {
|
|
let doc = &self.documents[self.active_doc];
|
|
let w = doc.engine.canvas_width();
|
|
let h = doc.engine.canvas_height();
|
|
let layers = doc.engine.layer_infos();
|
|
let mut meta = format!("\n\n[Active Canvas State: Size {}x{} px. Layers:", w, h);
|
|
for l in &layers {
|
|
meta.push_str(&format!(
|
|
"\n- \"{}\" (Type: {:?}, ID: {}, Visible: {}, Opacity: {:.0}%)",
|
|
l.name, l.layer_type, l.id, l.visible, l.opacity * 100.0
|
|
));
|
|
}
|
|
meta.push_str("]");
|
|
final_prompt.push_str(&meta);
|
|
}
|
|
|
|
// Build messages JSON for API
|
|
let messages_json = ai_chat::build_messages_json(
|
|
&self.ai_chat_state.messages,
|
|
&final_prompt,
|
|
SYSTEM_PROMPT,
|
|
);
|
|
|
|
// Clone config for the async task
|
|
let url = self.ai_chat_state.config.base_url.clone();
|
|
let model = self.ai_chat_state.config.model.clone();
|
|
|
|
// Spawn async streaming task — runs on Iced's executor
|
|
return Task::perform(
|
|
ai_chat::stream_llm_response(url, model, messages_json),
|
|
Message::AiChatStreamFinished,
|
|
);
|
|
}
|
|
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;
|
|
}
|
|
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) => {
|
|
// Handled by the streaming task internally
|
|
}
|
|
Message::AiChatStreamFinished(result) => {
|
|
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,
|
|
});
|
|
}
|
|
Err(e) => {
|
|
self.ai_chat_state.messages.push(ChatMessage {
|
|
role: "assistant".to_string(),
|
|
content: format!("❌ Connection Error: {}", e),
|
|
is_reasoning: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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_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;
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
Message::AiScriptClear => {
|
|
self.ai_script_prompt.clear();
|
|
self.ai_script_output.clear();
|
|
self.ai_script_error = 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;
|
|
}
|
|
Err(e) => {
|
|
self.ai_script_error = Some(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Clipboard ─────────────────────────────────
|
|
Message::CopyImage => {
|
|
let doc = &self.documents[self.active_doc];
|
|
let w = doc.engine.canvas_width();
|
|
let h = doc.engine.canvas_height();
|
|
|
|
// Check if there's a selection
|
|
if let Some(ref mask) = doc.selection_mask {
|
|
// Copy selected pixels only
|
|
let pixels = doc.composite_raw.clone();
|
|
if let Some(transform) = selection::clipboard::extract_masked_pixels(&pixels, mask, w, h) {
|
|
self.documents[self.active_doc].internal_clipboard = Some(transform);
|
|
return Task::perform(async {}, |_| Message::NoOp);
|
|
}
|
|
}
|
|
|
|
// No selection - copy entire canvas
|
|
let pixels = doc.composite_raw.clone();
|
|
return Task::perform(
|
|
async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) },
|
|
Message::ImageCopied,
|
|
);
|
|
}
|
|
Message::PasteImage => {
|
|
let doc = &self.documents[self.active_doc];
|
|
|
|
// Check internal clipboard first
|
|
if let Some(ref transform) = doc.internal_clipboard {
|
|
// Enter transform mode with the clipboard content
|
|
self.documents[self.active_doc].selection_transform = Some(transform.clone());
|
|
self.documents[self.active_doc].transform_drag_handle = TransformHandle::None;
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
|
|
// Fall back to system clipboard
|
|
return Task::perform(
|
|
async { crate::io::clipboard::paste_image_from_clipboard() },
|
|
Message::ImagePasted,
|
|
);
|
|
}
|
|
Message::CopyText(text) => {
|
|
let text = text.clone();
|
|
return Task::perform(
|
|
async move { crate::io::clipboard::copy_text_to_clipboard(&text) },
|
|
|_| Message::NoOp,
|
|
);
|
|
}
|
|
Message::PasteText => {
|
|
return Task::perform(
|
|
async { crate::io::clipboard::paste_text_from_clipboard() },
|
|
Message::ClipboardResult,
|
|
);
|
|
}
|
|
Message::ClipboardResult(Ok(Some(text))) => {
|
|
log::info!("Pasted text: {}", &text[..text.len().min(50)]);
|
|
}
|
|
Message::ClipboardResult(Ok(None)) => {
|
|
log::info!("Clipboard is empty");
|
|
}
|
|
Message::ClipboardResult(Err(e)) => {
|
|
log::error!("Clipboard error: {}", e);
|
|
}
|
|
Message::ImageCopied(Ok(())) => {
|
|
log::info!("Image copied to clipboard");
|
|
}
|
|
Message::ImageCopied(Err(e)) => {
|
|
log::error!("Failed to copy image: {}", e);
|
|
}
|
|
Message::ImagePasted(Ok(Some((pixels, w, h)))) => {
|
|
log::info!("Pasted image: {}x{}", w, h);
|
|
let engine = &mut self.documents[self.active_doc].engine;
|
|
engine.create_layer_from_data("Pasted", pixels, w, h);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Message::ImagePasted(Ok(None)) => {
|
|
log::info!("No image in clipboard");
|
|
}
|
|
Message::ImagePasted(Err(e)) => {
|
|
log::error!("Failed to paste image: {}", e);
|
|
}
|
|
|
|
// ── File I/O (with rfd) ───────────────────────
|
|
Message::OpenFileRfd => {
|
|
return Task::perform(
|
|
async {
|
|
rfd::AsyncFileDialog::new()
|
|
.set_title("Open Image")
|
|
.add_filter("All Supported Types", &[
|
|
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
|
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
|
"hcie", "psd", "kra",
|
|
])
|
|
.add_filter("Images", &[
|
|
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
|
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
|
])
|
|
.add_filter("HCIE Native", &["hcie"])
|
|
.add_filter("Photoshop", &["psd"])
|
|
.add_filter("Krita", &["kra"])
|
|
.pick_file()
|
|
.await
|
|
.map(|handle| handle.path().to_path_buf())
|
|
},
|
|
Message::FileDialogClosed,
|
|
);
|
|
}
|
|
Message::SaveFileAs => {
|
|
return Task::perform(
|
|
async {
|
|
rfd::AsyncFileDialog::new()
|
|
.set_title("Save As")
|
|
.add_filter("HCIE Native", &["hcie"])
|
|
.add_filter("Photoshop", &["psd"])
|
|
.add_filter("Krita", &["kra"])
|
|
.add_filter("PNG", &["png"])
|
|
.add_filter("JPEG", &["jpg", "jpeg"])
|
|
.add_filter("WebP", &["webp"])
|
|
.add_filter("AVIF", &["avif"])
|
|
.add_filter("BMP", &["bmp"])
|
|
.add_filter("GIF", &["gif"])
|
|
.add_filter("TIFF", &["tiff", "tif"])
|
|
.add_filter("TGA", &["tga"])
|
|
.add_filter("OpenEXR", &["exr"])
|
|
.add_filter("All Supported Types", &[
|
|
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
|
|
"avif", "tga", "exr", "psd", "kra", "hcie",
|
|
])
|
|
.save_file()
|
|
.await
|
|
.map(|handle| handle.path().to_path_buf())
|
|
},
|
|
Message::FileDialogClosed,
|
|
);
|
|
}
|
|
Message::FileDialogClosed(Some(path)) => {
|
|
let path_str = path.to_string_lossy().to_string();
|
|
let ext = path.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("png")
|
|
.to_lowercase();
|
|
|
|
if self.active_dialog == ActiveDialog::None {
|
|
// Open file — route multi-layer formats to dedicated importers
|
|
let engine = &mut self.documents[self.active_doc].engine;
|
|
let result = match ext.as_str() {
|
|
"psd" => engine.import_psd(&path_str),
|
|
"kra" => engine.import_kra(&path_str),
|
|
"hcie" => engine.load_native(&path_str),
|
|
_ => engine.open_image(&path_str),
|
|
};
|
|
match result {
|
|
Ok(()) => {
|
|
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
|
self.documents[self.active_doc].name = path.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "Untitled".to_string());
|
|
self.documents[self.active_doc].source_path = Some(path.clone());
|
|
self.add_recent_file(&path);
|
|
self.documents[self.active_doc].full_upload.replace(true);
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Err(e) => log::error!("Failed to open: {}", e),
|
|
}
|
|
} else {
|
|
// Save file — route multi-layer formats to dedicated exporters
|
|
let engine = &self.documents[self.active_doc].engine;
|
|
let result = match ext.as_str() {
|
|
"hcie" => engine.save_native(&path_str),
|
|
"psd" => engine.export_psd(&path_str),
|
|
"kra" => engine.export_kra(&path_str),
|
|
_ => engine.save_as(&path_str, &ext),
|
|
};
|
|
match result {
|
|
Ok(()) => log::info!("Saved to {}", path_str),
|
|
Err(e) => log::error!("Failed to save: {}", e),
|
|
}
|
|
self.active_dialog = ActiveDialog::None;
|
|
}
|
|
}
|
|
Message::FileDialogClosed(None) => {
|
|
// User cancelled
|
|
}
|
|
|
|
// ── Dock Layout ───────────────────────────────
|
|
Message::PaneClicked(pane) => {
|
|
self.dock.focus(pane);
|
|
}
|
|
Message::PaneDragged(drag_event) => {
|
|
if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = drag_event {
|
|
if let iced::widget::pane_grid::Target::Pane(target_pane, _) = target {
|
|
self.dock.pane_grid.swap(pane, target_pane);
|
|
}
|
|
}
|
|
}
|
|
Message::PaneResized(resize_event) => {
|
|
self.dock.pane_grid.resize(resize_event.split, resize_event.ratio);
|
|
}
|
|
Message::PaneClose(pane) => {
|
|
// Don't close canvas panes
|
|
if let Some(PaneType::Canvas) = self.dock.pane_type(pane) {
|
|
return Task::none();
|
|
}
|
|
self.dock.close_pane(pane);
|
|
}
|
|
Message::PaneMaximize(pane) => {
|
|
self.dock.toggle_maximize(pane);
|
|
}
|
|
|
|
// ── 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) => {
|
|
let mut engine = Engine::new(w, h);
|
|
engine.pre_tile_all_layers();
|
|
let composite_raw = engine.get_composite_pixels();
|
|
let cached_layers = engine.layer_infos();
|
|
let history_len = engine.history_len();
|
|
let cached_history: Vec<(usize, String)> = (0..history_len)
|
|
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
|
.collect();
|
|
let history_current = engine.history_current();
|
|
self.documents.push(IcedDocument {
|
|
engine,
|
|
composite_pixels: Arc::new(composite_raw.clone()),
|
|
composite_raw,
|
|
name: "Untitled".to_string(),
|
|
source_path: None,
|
|
modified: false,
|
|
zoom: 1.0,
|
|
pan_offset: Vector::ZERO,
|
|
cached_layers,
|
|
cached_history,
|
|
history_current,
|
|
selection_rect: None,
|
|
vector_draw: None,
|
|
pane_size: (800.0, 600.0),
|
|
render_generation: 0,
|
|
dirty_region: std::cell::RefCell::new(None),
|
|
full_upload: std::cell::RefCell::new(true),
|
|
selection_transform: None,
|
|
transform_drag_handle: TransformHandle::None,
|
|
transform_drag_start: None,
|
|
transform_original: None,
|
|
internal_clipboard: None,
|
|
selection_mask: None,
|
|
selection_bounds: None,
|
|
crop_state: CropState::default(),
|
|
});
|
|
}
|
|
|
|
|
|
Message::DocSwitch(idx) => {
|
|
if idx < self.documents.len() {
|
|
self.active_doc = idx;
|
|
}
|
|
}
|
|
|
|
Message::OpenFile => {
|
|
return Task::perform(
|
|
async {
|
|
rfd::AsyncFileDialog::new()
|
|
.set_title("Open Image")
|
|
.add_filter("All Supported Types", &[
|
|
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
|
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
|
"hcie", "psd", "kra",
|
|
])
|
|
.add_filter("Images", &[
|
|
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
|
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
|
])
|
|
.add_filter("HCIE Native", &["hcie"])
|
|
.add_filter("Photoshop", &["psd"])
|
|
.add_filter("Krita", &["kra"])
|
|
.pick_file()
|
|
.await
|
|
.map(|handle| handle.path().to_path_buf())
|
|
},
|
|
Message::FileDialogClosed,
|
|
);
|
|
}
|
|
|
|
Message::FileOpened(Ok(path)) => {
|
|
log::info!("File opened: {:?}", path);
|
|
}
|
|
|
|
Message::FileOpened(Err(e)) => {
|
|
log::error!("Failed to open file: {}", e);
|
|
}
|
|
|
|
Message::SaveFile => {
|
|
return Task::perform(
|
|
async {
|
|
rfd::AsyncFileDialog::new()
|
|
.set_title("Save As")
|
|
.add_filter("HCIE Native", &["hcie"])
|
|
.add_filter("Photoshop", &["psd"])
|
|
.add_filter("Krita", &["kra"])
|
|
.add_filter("PNG", &["png"])
|
|
.add_filter("JPEG", &["jpg", "jpeg"])
|
|
.add_filter("WebP", &["webp"])
|
|
.add_filter("AVIF", &["avif"])
|
|
.add_filter("BMP", &["bmp"])
|
|
.add_filter("GIF", &["gif"])
|
|
.add_filter("TIFF", &["tiff", "tif"])
|
|
.add_filter("TGA", &["tga"])
|
|
.add_filter("OpenEXR", &["exr"])
|
|
.add_filter("All Supported Types", &[
|
|
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
|
|
"avif", "tga", "exr", "psd", "kra", "hcie",
|
|
])
|
|
.save_file()
|
|
.await
|
|
.map(|handle| handle.path().to_path_buf())
|
|
},
|
|
Message::FileDialogClosed,
|
|
);
|
|
}
|
|
|
|
// ── Menu ─────────────────────────────────────
|
|
Message::MenuOpen(idx) => {
|
|
if self.active_menu == Some(idx) {
|
|
self.active_menu = None;
|
|
} else {
|
|
self.active_menu = Some(idx);
|
|
}
|
|
}
|
|
Message::MenuClose => {
|
|
// If 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() {
|
|
// Cancel transform if active
|
|
let doc = &mut self.documents[self.active_doc];
|
|
if let Some(original) = doc.transform_original.take() {
|
|
doc.selection_transform = Some(original);
|
|
} else {
|
|
doc.selection_transform = None;
|
|
}
|
|
doc.transform_drag_start = None;
|
|
doc.transform_drag_handle = TransformHandle::None;
|
|
} else if self.active_menu.is_some() {
|
|
self.active_menu = None;
|
|
} else if self.active_dialog != ActiveDialog::None {
|
|
self.active_dialog = ActiveDialog::None;
|
|
}
|
|
}
|
|
Message::OpenRecentFile(idx) => {
|
|
self.active_menu = None;
|
|
// Clone the path to avoid borrow conflict
|
|
let path_str = self.recent_files.get(idx).map(|e| e.path.clone());
|
|
if let Some(path_str) = path_str {
|
|
let path = std::path::PathBuf::from(&path_str);
|
|
if path.exists() {
|
|
let ext = path.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let result = match ext.as_str() {
|
|
"psd" => self.documents[self.active_doc].engine.import_psd(&path_str),
|
|
"kra" => self.documents[self.active_doc].engine.import_kra(&path_str),
|
|
"hcie" => self.documents[self.active_doc].engine.load_native(&path_str),
|
|
_ => self.documents[self.active_doc].engine.open_image(&path_str),
|
|
};
|
|
match result {
|
|
Ok(()) => {
|
|
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
|
self.documents[self.active_doc].name = path.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "Untitled".to_string());
|
|
self.documents[self.active_doc].source_path = Some(path.clone());
|
|
self.add_recent_file(&path);
|
|
let composite = self.documents[self.active_doc].engine.get_composite_pixels();
|
|
self.documents[self.active_doc].composite_raw = composite;
|
|
self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(self.documents[self.active_doc].composite_raw.clone());
|
|
self.documents[self.active_doc].full_upload.replace(true);
|
|
self.documents[self.active_doc].render_generation = self.documents[self.active_doc].render_generation.wrapping_add(1);
|
|
self.documents[self.active_doc].cached_layers = self.documents[self.active_doc].engine.layer_infos();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Err(e) => log::error!("Failed to open recent file: {}", e),
|
|
}
|
|
} else {
|
|
self.recent_files.retain(|e| e.path != path_str);
|
|
}
|
|
}
|
|
}
|
|
Message::ClearRecentFiles => {
|
|
self.active_menu = None;
|
|
self.recent_files.clear();
|
|
save_recent_files(&self.recent_files);
|
|
}
|
|
Message::MenuAction(menu_idx, item_idx) => {
|
|
self.active_menu = None;
|
|
match (menu_idx, item_idx) {
|
|
// ══════════════════════════════════════════
|
|
// File menu (0) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
(0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New...
|
|
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open...
|
|
// (0, 2) = Open & Place...
|
|
// (0, 3) = Open More (submenu)
|
|
// (0, 4) = separator
|
|
// (0, 5) = Share
|
|
// (0, 6) = separator
|
|
(0, 7) => return Task::perform(async {}, |_| Message::SaveFile), // Save
|
|
(0, 8) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save as PSD
|
|
// (0, 9) = Save More (submenu)
|
|
// (0, 10) = Export as (submenu)
|
|
(0, 11) => return Task::perform(async {}, |_| Message::SaveFileAs), // Print...
|
|
// (0, 12) = separator
|
|
// (0, 13) = Export Layers...
|
|
// (0, 14) = Export Color Lookup...
|
|
// (0, 15) = File Info...
|
|
// (0, 16) = separator
|
|
// (0, 17) = Automate (submenu)
|
|
// (0, 18) = Script...
|
|
|
|
// ══════════════════════════════════════════
|
|
// Edit menu (1) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
(1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo / Redo
|
|
(1, 1) => return Task::perform(async {}, |_| Message::Redo), // Step Forward
|
|
(1, 2) => return Task::perform(async {}, |_| Message::Undo), // Step Backward
|
|
// (1, 3) = separator
|
|
// (1, 4) = Fade...
|
|
// (1, 5) = separator
|
|
(1, 6) => return Task::perform(async {}, |_| Message::CopyImage), // Cut
|
|
(1, 7) => return Task::perform(async {}, |_| Message::CopyImage), // Copy
|
|
(1, 8) => return Task::perform(async {}, |_| Message::CopyImage), // Copy Merged
|
|
(1, 9) => return Task::perform(async {}, |_| Message::PasteImage), // Paste
|
|
// (1, 10) = Clear
|
|
// (1, 11) = separator
|
|
// (1, 12) = Fill...
|
|
// (1, 13) = Stroke...
|
|
// (1, 14) = separator
|
|
// (1, 15) = Content-Aware Scale
|
|
// (1, 16) = Puppet Warp
|
|
// (1, 17) = Perspective Warp
|
|
// (1, 18) = Free Transform
|
|
// (1, 19) = Transform (submenu)
|
|
// (1, 20) = Auto-Align
|
|
// (1, 21) = Auto-Blend
|
|
// (1, 22) = separator
|
|
// (1, 23) = Assign Profile (submenu)
|
|
// (1, 24) = Convert to Profile (submenu)
|
|
// (1, 25) = separator
|
|
// (1, 26) = Define New (submenu)
|
|
// (1, 27) = Preset Manager...
|
|
// (1, 28) = Preferences...
|
|
// (1, 29) = Local Storage...
|
|
|
|
// ══════════════════════════════════════════
|
|
// Image menu (2) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
// (2, 0) = Mode (submenu)
|
|
// (2, 1) = Adjustments (submenu)
|
|
// (2, 2) = Auto Tone
|
|
// (2, 3) = Auto Contrast
|
|
// (2, 4) = Auto Color
|
|
// (2, 5) = separator
|
|
// (2, 6) = Reduce Colors...
|
|
// (2, 7) = Vectorize Bitmap...
|
|
// (2, 8) = Wavelet Decompose...
|
|
// (2, 9) = separator
|
|
(2, 10) => {
|
|
// Canvas Size...
|
|
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;
|
|
}
|
|
(2, 11) => {
|
|
// Image Size...
|
|
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;
|
|
}
|
|
// (2, 12) = Transform (submenu)
|
|
// (2, 13) = Crop
|
|
// (2, 14) = Trim...
|
|
// (2, 15) = Reveal All
|
|
// (2, 16) = separator
|
|
// (2, 17) = Duplicate
|
|
// (2, 18) = Apply Image...
|
|
// (2, 19) = separator
|
|
// (2, 20) = Variables...
|
|
|
|
// ══════════════════════════════════════════
|
|
// Layer menu (3) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
(3, 0) => return Task::perform(async {}, |_| Message::LayerAdd), // New (submenu)
|
|
// (3, 1) = Duplicate Layer — TODO: engine doesn't support yet
|
|
// (3, 2) = Duplicate Into...
|
|
(3, 3) => { // Delete
|
|
let id = self.documents[self.active_doc].engine.active_layer_id();
|
|
self.documents[self.active_doc].engine.delete_layer(id);
|
|
}
|
|
// (3, 4) = separator
|
|
// (3, 5) = Text (submenu)
|
|
(3, 6) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Style (submenu)
|
|
// (3, 7) = separator
|
|
// (3, 8) = New Fill Layer (submenu)
|
|
// (3, 9) = New Adjustment Layer (submenu)
|
|
// (3, 10) = separator
|
|
// (3, 11) = Raster Mask (submenu)
|
|
// (3, 12) = Vector Mask (submenu)
|
|
// (3, 13) = Clipping Mask (disabled)
|
|
// (3, 14) = separator
|
|
// (3, 15) = Smart Object (submenu)
|
|
// (3, 16) = Rasterize
|
|
// (3, 17) = Rasterize Layer Style
|
|
// (3, 18) = separator
|
|
// (3, 19) = Group Layers
|
|
// (3, 20) = Arrange (submenu)
|
|
// (3, 21) = Combine Shapes (submenu)
|
|
// (3, 22) = Animation (submenu)
|
|
// (3, 23) = separator
|
|
(3, 24) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down
|
|
(3, 25) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image
|
|
// (3, 26) = Defringe
|
|
|
|
// ══════════════════════════════════════════
|
|
// Select menu (4) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
(4, 0) => { // All
|
|
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
|
|
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
|
|
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
|
|
}
|
|
(4, 1) => { self.documents[self.active_doc].selection_rect = None; } // Deselect
|
|
(4, 2) => { // Inverse
|
|
self.documents[self.active_doc].engine.selection_invert();
|
|
self.refresh_composite_if_needed();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
// (4, 3) = separator
|
|
// (4, 4) = Remove BG
|
|
// (4, 5) = Color Range...
|
|
// (4, 6) = Magic Cut...
|
|
// (4, 7) = Subject
|
|
// (4, 8) = separator
|
|
// (4, 9) = Refine Edge...
|
|
// (4, 10) = Modify (submenu)
|
|
(4, 11) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow
|
|
(4, 12) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } // Shrink
|
|
(4, 13) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } // Feather
|
|
(4, 14) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Border"); } // Border
|
|
(4, 15) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Smooth"); } // Smooth
|
|
// (4, 16) = separator
|
|
// (4, 17) = Similar
|
|
// (4, 18) = separator
|
|
// (4, 19) = Transform Selection
|
|
// (4, 20) = Quick Mask Mode
|
|
// (4, 21) = separator
|
|
// (4, 22) = Load Selection
|
|
// (4, 23) = Save Selection
|
|
|
|
// ══════════════════════════════════════════
|
|
// Filter menu (5) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
// (5, 0) = Last Filter
|
|
// (5, 1) = separator
|
|
// (5, 2) = Filter Gallery...
|
|
// (5, 3) = Lens Correction...
|
|
// (5, 4) = Camera Raw...
|
|
// (5, 5) = Liquify...
|
|
// (5, 6) = Vanishing Point...
|
|
// (5, 7) = separator
|
|
// (5, 8) = 3D (submenu)
|
|
// (5, 9) = Blur (submenu)
|
|
// (5, 10) = Blur Gallery (submenu)
|
|
// (5, 11) = Distort (submenu)
|
|
// (5, 12) = Noise (submenu)
|
|
// (5, 13) = Pixelate (submenu)
|
|
// (5, 14) = Render (submenu)
|
|
// (5, 15) = Sharpen (submenu)
|
|
// (5, 16) = Stylize (submenu)
|
|
// (5, 17) = Other (submenu)
|
|
// (5, 18) = Fourier (submenu)
|
|
|
|
// ══════════════════════════════════════════
|
|
// View menu (6) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
(6, 0) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)), // Zoom In
|
|
(6, 1) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)), // Zoom Out
|
|
(6, 2) => { // Fit The Area
|
|
self.documents[self.active_doc].zoom = 1.0;
|
|
}
|
|
(6, 3) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // Pixel to Pixel (100%)
|
|
// (6, 4) = Pattern Preview
|
|
// (6, 5) = separator
|
|
// (6, 6) = Mode (submenu)
|
|
// (6, 7) = Extras
|
|
// (6, 8) = Show (submenu)
|
|
// (6, 9) = separator
|
|
// (6, 10) = Rulers
|
|
// (6, 11) = separator
|
|
// (6, 12) = Snap
|
|
// (6, 13) = Snap To (submenu)
|
|
// (6, 14) = separator
|
|
// (6, 15) = Lock Guides
|
|
// (6, 16) = Clear Guides
|
|
// (6, 17) = Add Guides...
|
|
// (6, 18) = Guides from Layer
|
|
// (6, 19) = separator
|
|
// (6, 20) = Clear Slices
|
|
|
|
// ══════════════════════════════════════════
|
|
// Window menu (7) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
(7, 0) => self.dock.reopen_pane(PaneType::Layers),
|
|
(7, 1) => self.dock.reopen_pane(PaneType::History),
|
|
(7, 2) => self.dock.reopen_pane(PaneType::Brushes),
|
|
(7, 3) => self.dock.reopen_pane(PaneType::Filters),
|
|
(7, 4) => self.dock.reopen_pane(PaneType::ColorPicker),
|
|
(7, 5) => self.dock.reopen_pane(PaneType::Properties),
|
|
(7, 6) => self.dock.reopen_pane(PaneType::AiChat),
|
|
(7, 7) => self.dock.reopen_pane(PaneType::Geometry),
|
|
(7, 8) => self.dock.reopen_pane(PaneType::Script),
|
|
(7, 9) => self.dock.reopen_pane(PaneType::LayerDetails),
|
|
(7, 10) => self.dock.reopen_pane(PaneType::AiScript),
|
|
|
|
// ══════════════════════════════════════════
|
|
// More menu (8) — Photopea-style
|
|
// ══════════════════════════════════════════
|
|
// (8, 0) = More (submenu)
|
|
// (8, 1) = separator
|
|
// (8, 2) = Plugins
|
|
// (8, 3) = Actions
|
|
// (8, 4) = Adjustments
|
|
// (8, 5) = Brush
|
|
// (8, 6) = Channels
|
|
// (8, 7) = Character
|
|
// (8, 8) = Character Styles
|
|
// (8, 9) = Color
|
|
// (8, 10) = Glyphs
|
|
// (8, 11) = Histogram
|
|
// (8, 12) = History
|
|
// (8, 13) = Info
|
|
// (8, 14) = Navigator
|
|
// (8, 15) = Notes
|
|
// (8, 16) = Paragraph
|
|
// (8, 17) = Paths
|
|
// (8, 18) = Properties
|
|
// (8, 19) = Style
|
|
// (8, 20) = Swatches
|
|
// (8, 21) = Tool Presets
|
|
// (8, 22) = Vector Info
|
|
|
|
// Ignore separators, submenus, and unimplemented items
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ── Window controls ──────────────────────────
|
|
Message::WindowDrag => {
|
|
if let Some(id) = self.window_id {
|
|
return iced::window::drag(id);
|
|
}
|
|
}
|
|
Message::WindowMinimize => {
|
|
if let Some(id) = self.window_id {
|
|
return iced::window::minimize(id, true);
|
|
}
|
|
}
|
|
Message::WindowMaximize => {
|
|
if let Some(id) = self.window_id {
|
|
return iced::window::toggle_maximize(id);
|
|
}
|
|
}
|
|
Message::WindowClose => {
|
|
// Check if any document has unsaved changes
|
|
let has_unsaved = self.documents.iter().any(|d| d.modified);
|
|
if has_unsaved {
|
|
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);
|
|
}
|
|
|
|
// ── Image Viewer ─────────────────────────────────
|
|
Message::ViewerToggle => {
|
|
self.viewer_active = !self.viewer_active;
|
|
if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() {
|
|
// Initialize to home directory on first open
|
|
let start_dir = dirs::home_dir()
|
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
|
self.viewer_state = crate::viewer::ViewerState::new(start_dir);
|
|
}
|
|
if self.viewer_active {
|
|
self.viewer_state.load_preview();
|
|
}
|
|
}
|
|
Message::ViewerNavigate(path) => {
|
|
self.viewer_state.set_dir(path);
|
|
self.viewer_state.load_preview();
|
|
}
|
|
Message::ViewerSelectFile(path) => {
|
|
// Select the file in the grid
|
|
if let Some(idx) = self.viewer_state.images.iter().position(|p| p == &path) {
|
|
self.viewer_state.active_image_idx = idx;
|
|
self.viewer_state.load_preview();
|
|
self.viewer_state.view_mode = crate::viewer::ViewerMode::Viewer;
|
|
}
|
|
}
|
|
Message::ViewerOpenFile(path) => {
|
|
// Open the file in the editor — exit viewer and open the file
|
|
self.viewer_active = false;
|
|
let path_str = path.to_string_lossy().to_string();
|
|
let ext = path.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let result = match ext.as_str() {
|
|
"psd" => self.documents[0].engine.import_psd(&path_str),
|
|
"kra" => self.documents[0].engine.import_kra(&path_str),
|
|
"hcie" => self.documents[0].engine.load_native(&path_str),
|
|
_ => self.documents[0].engine.open_image(&path_str),
|
|
};
|
|
match result {
|
|
Ok(()) => {
|
|
self.documents[0].engine.pre_tile_all_layers();
|
|
self.documents[0].name = path.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "Untitled".to_string());
|
|
self.documents[0].source_path = Some(path.clone());
|
|
self.add_recent_file(&path);
|
|
let composite = self.documents[0].engine.get_composite_pixels();
|
|
self.documents[0].composite_raw = composite;
|
|
self.documents[0].composite_pixels = std::sync::Arc::new(self.documents[0].composite_raw.clone());
|
|
self.documents[0].full_upload.replace(true);
|
|
self.documents[0].render_generation = self.documents[0].render_generation.wrapping_add(1);
|
|
self.documents[0].cached_layers = self.documents[0].engine.layer_infos();
|
|
let history_len = self.documents[0].engine.history_len();
|
|
self.documents[0].cached_history = (0..history_len)
|
|
.filter_map(|i| self.documents[0].engine.history_description(i).map(|d| (i, d)))
|
|
.collect();
|
|
self.documents[0].history_current = self.documents[0].engine.history_current();
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to open file from viewer: {}", e);
|
|
}
|
|
}
|
|
}
|
|
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 {
|
|
if fs {
|
|
return iced::window::change_level(id, iced::window::Level::AlwaysOnTop);
|
|
} else {
|
|
return iced::window::change_level(id, iced::window::Level::Normal);
|
|
}
|
|
}
|
|
}
|
|
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::NoOp => {}
|
|
}
|
|
|
|
// Lazy-load thumbnails when viewer is active (max LAZY_LOAD_BUDGET per frame)
|
|
if self.viewer_active {
|
|
let batch = crate::viewer::thumbnail_grid::next_load_batch(&self.viewer_state);
|
|
for path in batch {
|
|
self.viewer_state.load_thumbnail(&path);
|
|
}
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
|
|
// ── View ────────────────────────────────────────────
|
|
|
|
/// Build the UI element tree.
|
|
///
|
|
/// Uses the dock system for the main layout. The title bar, menu bar,
|
|
/// dock grid, and status bar surround the content. Dialogs overlay everything.
|
|
pub fn view(&self) -> Element<'_, Message> {
|
|
let view_start = std::time::Instant::now();
|
|
let colors = self.theme_state.colors();
|
|
|
|
// When the viewer is active, show the full-screen image viewer
|
|
if self.viewer_active {
|
|
let viewer_panel = crate::viewer::view(&self.viewer_state, colors);
|
|
return container(viewer_panel)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.style(move |_theme| iced::widget::container::Style {
|
|
background: Some(iced::Background::Color(colors.bg_app)),
|
|
..Default::default()
|
|
})
|
|
.into();
|
|
}
|
|
|
|
let doc = &self.documents[self.active_doc];
|
|
|
|
// Title bar (now includes menu items and window controls)
|
|
let title_bar = panels::title_bar::view(
|
|
&doc.name,
|
|
doc.zoom,
|
|
doc.engine.canvas_width(),
|
|
doc.engine.canvas_height(),
|
|
colors,
|
|
self.active_menu,
|
|
);
|
|
|
|
// Toolbox — fixed 36px strip on the left edge (outside dock)
|
|
let toolbox = crate::sidebar::view(
|
|
&self.tool_state,
|
|
&self.fg_color,
|
|
&self.bg_color,
|
|
colors,
|
|
self.active_tool_slot,
|
|
self.sub_tools_open,
|
|
);
|
|
|
|
// Merged toolbar: icon buttons + tool options in one row
|
|
let toolbar = panels::toolbar::view(&self.tool_state, 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,
|
|
),
|
|
ActiveDialog::BrightnessContrast => dialogs::adjustments::brightness_contrast_view(
|
|
self.dialog_brightness,
|
|
self.dialog_contrast,
|
|
),
|
|
ActiveDialog::HueSaturation => dialogs::adjustments::hsl_view(
|
|
self.dialog_hue,
|
|
self.dialog_saturation,
|
|
self.dialog_lightness,
|
|
),
|
|
ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name),
|
|
ActiveDialog::SelectionOp(op) => dialogs::confirm::selection_op_view(
|
|
op,
|
|
self.dialog_selection_value,
|
|
1.0,
|
|
100.0,
|
|
),
|
|
ActiveDialog::LayerStyleDialog => {
|
|
let doc = &self.documents[self.active_doc];
|
|
let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id());
|
|
let styles: &'static [hcie_engine_api::LayerStyle] = Box::leak(styles.into_boxed_slice());
|
|
crate::panels::layer_styles::view(
|
|
styles,
|
|
self.selected_style,
|
|
colors,
|
|
self.show_style_color_picker,
|
|
self.style_color_picker_idx,
|
|
self.style_color_hsl,
|
|
self.layer_style_offset,
|
|
)
|
|
}
|
|
ActiveDialog::ImageSize => dialogs::image_size::view(
|
|
self.dialog_image_size_width,
|
|
self.dialog_image_size_height,
|
|
self.dialog_image_size_constrain,
|
|
),
|
|
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,
|
|
),
|
|
ActiveDialog::About => dialogs::about::view(),
|
|
};
|
|
|
|
// 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;
|
|
|
|
if has_dialog || has_menu || has_dock_profile_dialog {
|
|
let mut stack = iced::widget::Stack::new().push(content);
|
|
if has_dialog {
|
|
stack = stack.push(dialog_overlay);
|
|
}
|
|
if has_dock_profile_dialog {
|
|
stack = stack.push(dialogs::dock_profile::view(
|
|
&self.settings.dock_profiles,
|
|
self.active_dock_profile.as_deref(),
|
|
&self.dock_profile_new_name,
|
|
));
|
|
}
|
|
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files, self.theme_state.colors()) {
|
|
stack = stack.push(menu_overlay);
|
|
}
|
|
let _elapsed = view_start.elapsed();
|
|
// Performance log measuring Elm view reconstruction time.
|
|
// useful to track widget layout allocation and view model reconstruction times.
|
|
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
|
|
stack.width(Length::Fill).height(Length::Fill).into()
|
|
} else {
|
|
let _elapsed = view_start.elapsed();
|
|
// Performance log measuring Elm view reconstruction time.
|
|
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
|
|
content.into()
|
|
}
|
|
}
|
|
|
|
// ── Theme & Subscription ────────────────────────────
|
|
|
|
/// Return the application theme.
|
|
pub fn theme(&self) -> Theme {
|
|
Theme::Dark
|
|
}
|
|
|
|
/// Subscriptions — keyboard events for shortcuts.
|
|
///
|
|
/// Handles 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+X: Cut
|
|
/// - 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)
|
|
}
|
|
// Ctrl+X = Cut
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => {
|
|
Some(Message::CopyImage) // TODO: cut
|
|
}
|
|
// 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() == "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 = Fit to Screen (reset zoom to 100%)
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "0" && ctrl && !shift => {
|
|
Some(Message::CanvasZoomSet(1.0))
|
|
}
|
|
// 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))
|
|
}
|
|
|
|
// ── Named keys ───────────────────────────────
|
|
// Delete = Clear selection / deselect
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete) => {
|
|
Some(Message::Deselect)
|
|
}
|
|
// F11 = Fullscreen toggle
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::F11) => {
|
|
Some(Message::WindowMaximize)
|
|
}
|
|
// 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 = exit viewer, close menu, cancel dialog, deselect, or cancel crop
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => {
|
|
Some(Message::ViewerToggle)
|
|
}
|
|
// Enter = open file in viewer, or confirm crop
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) => {
|
|
Some(Message::ViewerEnter)
|
|
}
|
|
_ => None,
|
|
}
|
|
});
|
|
|
|
// Mouse events for dialog dragging
|
|
let mouse = iced::event::listen_with(|event, _status, _id| {
|
|
if let iced::Event::Mouse(mouse_event) = event {
|
|
match mouse_event {
|
|
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => {
|
|
// Drag start is handled by mouse_area on_press
|
|
None
|
|
}
|
|
iced::mouse::Event::CursorMoved { position } => {
|
|
Some(Message::LayerStyleDialogDragMove(position.x, position.y))
|
|
}
|
|
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
|
|
Some(Message::LayerStyleDialogDragEnd)
|
|
}
|
|
_ => None,
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
// Timer for marching ants animation - fires every 50ms when selection is active
|
|
let marching_ants = if self.documents.iter().any(|doc| doc.selection_bounds.is_some()) {
|
|
iced::Subscription::run_with_id(
|
|
std::time::Instant::now(),
|
|
iced::futures::stream::unfold((), |_| async {
|
|
// Wait 50ms between animation frames (~20fps for smooth marching ants)
|
|
iced::futures::future::ready(()).await;
|
|
// Use a simple yield to create animation frames
|
|
Some((Message::CompositeRefresh, ()))
|
|
}),
|
|
)
|
|
} else {
|
|
iced::Subscription::none()
|
|
};
|
|
|
|
iced::Subscription::batch(vec![keyboard, mouse, marching_ants])
|
|
}
|
|
}
|