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