Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/app.rs
T
phantom 58599bf8f9 feat: Enhance Bevel & Emboss effect with contour support and algorithm improvements
- Implemented contour lookup functionality for Bevel & Emboss effect to allow for custom contour curves.
- Added contour parameter to LayerStyle and updated related structures and functions.
- Improved emboss algorithm by fixing height field calculations and ensuring proper handling of outside pixels.
- Introduced tilt component for 3D emboss effect, enhancing visual depth.
- Optimized MAE grid search parameters for better performance and accuracy.
- Expanded Iced GUI panel for Bevel & Emboss to include contour selection and additional styling options.
- Updated example scripts to reflect changes in the API and new features.
2026-07-14 00:36:09 +03:00

2691 lines
134 KiB
Rust

//! Core Iced application state and update logic.
//!
//! Implements the Elm-architecture pattern for the HCIE image editor.
//! Manages per-document engine instances, tool state, compositing,
//! dock layout, keyboard shortcuts, and all UI interactions.
//!
//! ⚠️ PERFORMANCE-CRITICAL SECTIONS (DO NOT MODIFY):
//! - `refresh_composite_if_needed()` — uses engine's tiled compositing + dirty-region partial copy
//! - `IcedDocument.composite_raw` — mutable Vec<u8> for partial-copy compositing
//! - `IcedDocument.composite_handle_cache` — GPU `ImageHandle` reused when `render_generation` unchanged
//! - `CanvasZoom` handler — zoom-toward-cursor math
//! These sections are optimized for 4K performance. Modifying them
//! may cause rendering regressions or performance degradation.
use crate::dialogs;
use crate::dock::state::{DockState, PaneType};
use crate::io::tablet::TabletState;
use crate::panels;
use crate::theme::ThemeState;
use hcie_engine_api::{BlendMode, BrushTip, BrushStyle, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN};
use iced::widget::{column, container, text};
use iced::{Element, Length, Task, Theme, Vector};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Returns the path to the HCIE config directory (~/.config/hcie-iced/).
/// Creates the directory if it doesn't exist.
fn config_dir() -> PathBuf {
let dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("hcie-iced");
let _ = std::fs::create_dir_all(&dir);
dir
}
/// Returns the path to the recent files JSON config.
fn recent_files_path() -> PathBuf {
config_dir().join("recent_files.json")
}
/// Load recent files from disk.
fn load_recent_files() -> Vec<RecentFileEntry> {
let path = recent_files_path();
match std::fs::read_to_string(&path) {
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// Save recent files to disk.
fn save_recent_files(files: &[RecentFileEntry]) {
let path = recent_files_path();
if let Ok(json) = serde_json::to_string_pretty(files) {
let _ = std::fs::write(&path, json);
}
}
/// Active dialog type.
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum ActiveDialog {
None,
NewImage,
BrightnessContrast,
HueSaturation,
CloseConfirm,
SelectionOp(&'static str),
LayerStyleDialog,
}
/// Top-level application state.
pub struct HcieIcedApp {
/// All open documents.
pub documents: Vec<IcedDocument>,
/// Index of the currently active document.
pub active_doc: usize,
/// Current tool state.
pub tool_state: ToolState,
/// Foreground drawing color (RGBA).
pub fg_color: [u8; 4],
/// Background drawing color (RGBA).
pub bg_color: [u8; 4],
/// Last known cursor position from mouse_area events.
pub last_cursor_pos: Option<(f32, f32)>,
/// Cursor position in canvas-space for status bar display.
pub canvas_cursor_pos: Option<(u32, u32)>,
/// Currently active dialog (if any).
pub active_dialog: ActiveDialog,
/// Window ID for window control operations (drag, minimize, maximize, close).
pub window_id: Option<iced::window::Id>,
/// New Image dialog state.
pub dialog_new_name: String,
pub dialog_new_width: u32,
pub dialog_new_height: u32,
pub dialog_new_transparent: bool,
/// Adjustment dialog state.
pub dialog_brightness: f32,
pub dialog_contrast: f32,
pub dialog_hue: f32,
pub dialog_saturation: f32,
pub dialog_lightness: f32,
/// Selection operation dialog state.
pub dialog_selection_value: f32,
/// Selected filter.
pub selected_filter: Option<FilterType>,
/// Filter parameters (JSON) for the currently selected filter.
pub filter_params: serde_json::Value,
/// Filter preview state.
pub filter_preview_active: bool,
/// Dock layout state.
pub dock: DockState,
/// Shared tablet state for pressure input.
pub tablet_state: Arc<Mutex<TabletState>>,
/// Whether the app has been initialized (load_path processed).
pub initialized: bool,
/// Theme state for consistent styling.
pub theme_state: ThemeState,
/// Currently open menu index (None = no menu open).
pub active_menu: Option<usize>,
/// Recently opened files (most recent first, max 20).
pub recent_files: Vec<RecentFileEntry>,
/// Recent colors used (most recent first, max 40).
pub recent_colors: Vec<[u8; 4]>,
/// Selected style index in Layer Styles panel.
pub selected_style: usize,
/// Whether the color picker popup is open for a layer style.
pub show_style_color_picker: bool,
/// Index of the style whose color picker is open.
pub style_color_picker_idx: usize,
/// HSL color state for the style color picker (hue 0..360, sat 0..1, light 0..1).
pub style_color_hsl: (f32, f32, f32),
/// True when the color picker is editing the shadow color (BevelEmboss),
/// false when editing the highlight color or the single-color style's color.
pub style_color_is_shadow: bool,
/// Currently active color picker tab (0=Wheel, 1=Sliders, 2=Grid).
pub color_tab: usize,
/// Layer Style dialog drag offset from center (x, y) in pixels.
pub layer_style_offset: (f32, f32),
/// Whether the Layer Style dialog is being dragged.
pub layer_style_dragging: bool,
/// Last drag position for delta calculation.
pub layer_style_drag_start: Option<(f32, f32)>,
}
/// A recently opened file entry.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecentFileEntry {
pub path: String,
pub name: String,
}
/// Per-document state wrapping an engine instance.
pub struct IcedDocument {
pub engine: Engine,
/// Composite RGBA pixel data shared with the shader pipeline via Arc.
/// The shader's `prepare()` reads this and uploads only the dirty region
/// via `queue.write_texture()`. Updated in `refresh_composite_if_needed()`.
pub composite_pixels: Arc<Vec<u8>>,
/// Mutable composite buffer for partial-copy from engine.
/// After updating, this is wrapped in a new Arc for the shader.
composite_raw: Vec<u8>,
/// Document display name.
pub name: String,
/// Original file path, if loaded from disk.
pub source_path: Option<std::path::PathBuf>,
/// Whether the document has unsaved changes.
pub modified: bool,
/// Per-document zoom level.
pub zoom: f32,
/// Per-document pan offset in screen pixels.
pub pan_offset: Vector,
/// Cached layer infos for the layers panel.
pub cached_layers: Vec<hcie_engine_api::LayerInfo>,
/// Cached history items for the history panel.
pub cached_history: Vec<(usize, String)>,
/// Current history index.
pub history_current: i32,
/// Selection rectangle in canvas coordinates (for select tool).
pub selection_rect: Option<(f32, f32, f32, f32)>,
/// Vector shape being drawn (start pos, current pos).
pub vector_draw: Option<((f32, f32), (f32, f32))>,
/// Canvas pane size (width, height) in pixels — updated by the view.
pub pane_size: (f32, f32),
/// Render generation counter; incremented whenever the engine composite
/// buffer is refreshed.
pub render_generation: u64,
/// Dirty region from the last engine composite [x0, y0, x1, y1].
/// Set by `refresh_composite_if_needed()`, consumed by the shader.
pub dirty_region: std::cell::RefCell<Option<[u32; 4]>>,
/// Whether a full texture upload is needed (first frame, resize, file load).
/// Set to true after loading a file or changing canvas dimensions.
pub full_upload: std::cell::RefCell<bool>,
}
/// 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,
}
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(),
}
}
}
/// Messages the application handles.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Message {
// ── Tool selection ──────────────────────────────────
ToolSelected(Tool),
// ── Color ───────────────────────────────────────────
FgColorChanged([u8; 4]),
BgColorChanged([u8; 4]),
SwapColors,
ColorTabChanged(usize),
// ── Canvas interaction ──────────────────────────────
CanvasPointerPressed { x: f32, y: f32 },
CanvasPointerMoved { x: f32, y: f32 },
CanvasPointerReleased,
CanvasPanZoom { zoom: f32, pan_offset: Vector },
CanvasZoomSet(f32),
CanvasZoomRelative(f32),
CanvasSize((f32, f32)),
CanvasCursorPos { x: u32, y: u32 },
// ── Engine operations ───────────────────────────────
CompositeRefresh,
Undo,
Redo,
// ── Layer operations ────────────────────────────────
LayerSelect(u64),
LayerAdd,
LayerDelete(u64),
LayerToggleVisibility(u64),
LayerToggleLock(u64),
LayerSetOpacity(u64, f32),
LayerMergeDown,
LayerFlatten,
LayerMoveUp(u64),
LayerMoveDown(u64),
LayerSetBlendMode(u64, BlendMode),
LayerSetFillOpacity(u64, f32),
LayerAddGroup,
LayerToggleCollapse(u64),
// ── History ─────────────────────────────────────────
HistoryJumpTo(i32),
// ── Text tool ───────────────────────────────────────
TextSizeChanged(f32),
TextColorChanged([u8; 4]),
TextAlignChanged(String),
TextAccept,
TextCancel,
// ── Layer styles ────────────────────────────────────
LayerStyleToggle(u32),
LayerStyleSelect(usize),
LayerStyleApply,
LayerStyleCancel,
LayerStyleUpdateParam(usize, String, f32),
LayerStyleUpdateBlendMode(usize, String),
LayerStyleUpdateColor(usize, [u8; 4]),
LayerStyleUpdateString(usize, String, String),
LayerStyleUpdateShadowColor(usize, [u8; 4]),
LayerStyleUpdateShadowBlend(usize, String),
LayerStyleUpdateShadowOpacity(usize, f32),
ShowStyleColorPicker(usize),
ShowStyleShadowColorPicker(usize),
HideStyleColorPicker,
OpenLayerStyleDialog,
LayerStyleDialogDragStart(f32, f32),
LayerStyleDialogDragMove(f32, f32),
LayerStyleDialogDragEnd,
LayerStyleAdd,
// ── Brushes ─────────────────────────────────────────
BrushStyleSelected(BrushStyle),
BrushSizeChanged(f32),
BrushOpacityChanged(f32),
BrushHardnessChanged(f32),
// ── Filters ─────────────────────────────────────────
FilterSelect(FilterType),
FilterApply,
FilterReset,
FilterParamChanged(String, serde_json::Value),
// ── 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,
// ── Selection tools ─────────────────────────────────
SelectionDragStart(f32, f32),
SelectionDragMove(f32, f32),
SelectionDragEnd,
// ── Vector tools ────────────────────────────────────
VectorDrawStart(f32, f32),
VectorDrawMove(f32, f32),
VectorDrawEnd,
// ── AI Chat ─────────────────────────────────────────
AiChatInput(String),
AiChatSend,
AiChatClear,
// ── Clipboard ───────────────────────────────────────
CopyImage,
PasteImage,
CopyText(String),
PasteText,
ClipboardResult(Result<Option<String>, String>),
ImageCopied(Result<(), String>),
ImagePasted(Result<Option<(Vec<u8>, u32, u32)>, String>),
// ── File I/O (with rfd) ─────────────────────────────
OpenFileRfd,
SaveFileAs,
FileDialogClosed(Option<std::path::PathBuf>),
// ── Dock Layout ─────────────────────────────────────
PaneClicked(iced::widget::pane_grid::Pane),
PaneDragged(iced::widget::pane_grid::DragEvent),
PaneResized(iced::widget::pane_grid::ResizeEvent),
PaneClose(iced::widget::pane_grid::Pane),
PaneMaximize(iced::widget::pane_grid::Pane),
// ── File I/O ────────────────────────────────────────
NewDocument(u32, u32),
DocSwitch(usize),
OpenFile,
FileOpened(Result<std::path::PathBuf, String>),
SaveFile,
// ── Menu ─────────────────────────────────────────────
MenuOpen(usize),
MenuClose,
MenuAction(usize, usize), // (menu_index, item_index)
OpenRecentFile(usize), // index into recent_files
ClearRecentFiles,
// ── Window ───────────────────────────────────────────
WindowDrag,
WindowMinimize,
WindowMaximize,
WindowClose,
WindowId(iced::window::Id),
// ── Misc ────────────────────────────────────────────
NoOp,
}
/// 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()
}
}
/// Helper to combine two bounding boxes of dirty regions.
fn union_regions(r1: Option<[u32; 4]>, r2: [u32; 4]) -> [u32; 4] {
match r1 {
Some(r) => [
r[0].min(r2[0]),
r[1].min(r2[1]),
r[2].max(r2[2]),
r[3].max(r2[3]),
],
None => r2,
}
}
impl HcieIcedApp {
/// Create the initial application state.
///
/// If `load_path` is provided, the file will be opened after initialization.
/// Returns a Task to capture the window ID on startup.
pub fn new(load_path: Option<std::path::PathBuf>) -> (Self, Task<Message>) {
let mut engine = Engine::new(800, 600);
engine.pre_tile_all_layers();
let composite_raw = engine.get_composite_pixels();
let cached_layers = engine.layer_infos();
let history_len = engine.history_len();
let cached_history: Vec<(usize, String)> = (0..history_len)
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
.collect();
let history_current = engine.history_current();
let doc = IcedDocument {
engine,
composite_pixels: Arc::new(composite_raw.clone()),
composite_raw,
name: "Untitled".to_string(),
source_path: None,
modified: false,
zoom: 1.0,
pan_offset: Vector::ZERO,
cached_layers,
cached_history,
history_current,
selection_rect: None,
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
};
let mut app = Self {
documents: vec![doc],
active_doc: 0,
tool_state: ToolState::default(),
fg_color: [220, 50, 50, 255],
bg_color: [255, 255, 255, 255],
last_cursor_pos: None,
canvas_cursor_pos: None,
active_dialog: ActiveDialog::None,
window_id: None,
dialog_new_name: "Untitled".to_string(),
dialog_new_width: 800,
dialog_new_height: 600,
dialog_new_transparent: true,
dialog_brightness: 0.0,
dialog_contrast: 0.0,
dialog_hue: 0.0,
dialog_saturation: 0.0,
dialog_lightness: 0.0,
dialog_selection_value: 5.0,
selected_filter: None,
filter_params: serde_json::json!({}),
filter_preview_active: false,
dock: DockState::new(),
tablet_state: Arc::new(Mutex::new(TabletState::new())),
initialized: false,
theme_state: ThemeState::new(),
active_menu: None,
recent_files: load_recent_files(),
recent_colors: Vec::new(),
selected_style: 0,
show_style_color_picker: false,
style_color_picker_idx: 0,
style_color_hsl: (0.0, 0.0, 0.0),
style_color_is_shadow: false,
color_tab: 0,
layer_style_offset: (0.0, 0.0),
layer_style_dragging: false,
layer_style_drag_start: None,
};
// If a file path was provided, open it (route multi-layer formats to dedicated importers)
if let Some(path) = load_path {
let path_str = path.to_string_lossy().to_string();
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => app.documents[0].engine.import_psd(&path_str),
"kra" => app.documents[0].engine.import_kra(&path_str),
"hcie" => app.documents[0].engine.load_native(&path_str),
_ => app.documents[0].engine.open_image(&path_str),
};
match result {
Ok(()) => {
app.documents[0].engine.pre_tile_all_layers();
app.documents[0].name = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
app.documents[0].source_path = Some(path.clone());
app.add_recent_file(&path);
// Get the composite via the safe Vec-returning path.
// This calls get_composite_pixels() which internally uses
// composite_layers() — a pure-Rust function that returns Vec<u8>.
// This is safe and avoids the raw-pointer segfault in
// render_composite_region.
let composite = app.documents[0].engine.get_composite_pixels();
let size = composite.len();
log::info!("[startup] composite pixels: {} bytes ({}x{})", size,
app.documents[0].engine.canvas_width(), app.documents[0].engine.canvas_height());
app.documents[0].composite_raw = composite;
app.documents[0].composite_pixels = std::sync::Arc::new(app.documents[0].composite_raw.clone());
app.documents[0].full_upload.replace(true);
app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1);
// Refresh cached layer list and history for panels
app.documents[0].cached_layers = app.documents[0].engine.layer_infos();
let history_len = app.documents[0].engine.history_len();
app.documents[0].cached_history = (0..history_len)
.filter_map(|i| app.documents[0].engine.history_description(i).map(|d| (i, d)))
.collect();
app.documents[0].history_current = app.documents[0].engine.history_current();
}
Err(e) => {
log::error!("Failed to open file on startup: {}", e);
}
}
}
app.initialized = true;
let init_task = iced::window::get_oldest().map(|opt_id| {
if let Some(id) = opt_id {
Message::WindowId(id)
} else {
Message::NoOp
}
});
(app, init_task)
}
/// Return a mutable reference to the active document.
fn active_document_mut(&mut self) -> &mut IcedDocument {
self.documents.get_mut(self.active_doc).expect("no active document")
}
/// Convert pane-relative coordinates to canvas-space coordinates.
///
/// The canvas image is centered in the pane, with `pan_offset` as an
/// additional offset from center. This function accounts for both.
fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
let doc = &self.documents[self.active_doc];
let zoom = doc.zoom;
let (pane_w, pane_h) = doc.pane_size;
let engine_w = doc.engine.canvas_width() as f32;
let engine_h = doc.engine.canvas_height() as f32;
let display_w = engine_w * zoom;
let display_h = engine_h * zoom;
// Canvas image position = center of pane + pan_offset
let canvas_origin_x = (pane_w - display_w) / 2.0 + doc.pan_offset.x;
let canvas_origin_y = (pane_h - display_h) / 2.0 + doc.pan_offset.y;
// Convert from pane-space to canvas-space
let canvas_x = (sx - canvas_origin_x) / zoom;
let canvas_y = (sy - canvas_origin_y) / zoom;
if canvas_x >= 0.0 && canvas_x < engine_w
&& canvas_y >= 0.0 && canvas_y < engine_h
{
Some((canvas_x, canvas_y))
} else {
None
}
}
/// Add a file to the recent files list (most recent first, max 20) and persist to disk.
fn add_recent_file(&mut self, path: &std::path::Path) {
let path_str = path.to_string_lossy().to_string();
let name = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path_str.clone());
// Remove duplicates
self.recent_files.retain(|e| e.path != path_str);
// Insert at front
self.recent_files.insert(0, RecentFileEntry { path: path_str, name });
// Cap at 20
self.recent_files.truncate(20);
// Persist to disk
save_recent_files(&self.recent_files);
}
/// Add a color to the recent colors list (most recent first, max 40).
fn add_recent_color(&mut self, color: [u8; 4]) {
self.recent_colors.retain(|c| *c != color);
self.recent_colors.insert(0, color);
self.recent_colors.truncate(40);
}
/// Refresh the composite buffer using incremental dirty-region compositing.
///
/// Uses `render_composite_region()` which only re-composites dirty tiles.
/// Only the dirty region is copied from the engine's scratch buffer into
/// the local composite buffer, avoiding a full 33 MB copy for small strokes.
fn refresh_composite_if_needed(&mut self) {
let doc = &mut self.documents[self.active_doc];
if doc.engine.is_composite_dirty() {
// Use get_composite_pixels() instead of render_composite_region()
// to avoid segfaults when canvas dimensions change (e.g. after PSD import).
// get_composite_pixels() returns a safe Vec<u8> via composite_layers().
let composite = doc.engine.get_composite_pixels();
let size = composite.len();
if size > 0 {
doc.composite_raw = composite;
// Update the shared Arc — if no other reference exists, mutate in-place
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
pixels.resize(doc.composite_raw.len(), 0);
pixels.copy_from_slice(&doc.composite_raw);
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
doc.full_upload.replace(true);
doc.dirty_region.replace(None);
doc.render_generation = doc.render_generation.wrapping_add(1);
}
doc.engine.clear_dirty_flags();
}
// Always refresh cached panel data (cheap operation)
doc.cached_layers = doc.engine.layer_infos();
let history_len = doc.engine.history_len();
doc.cached_history = (0..history_len)
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
.collect();
doc.history_current = doc.engine.history_current();
}
/// Apply brush parameters from tool state to the engine.
///
/// Called before starting a stroke to ensure the engine uses
/// the current brush size, opacity, hardness, and style.
fn apply_brush_params(&mut self) {
let tip = build_brush_tip(&self.tool_state);
self.documents[self.active_doc].engine.set_brush_tip(tip);
}
// ── Update ──────────────────────────────────────────
/// Handle a message and return an optional command.
pub fn update(&mut self, message: Message) -> Task<Message> {
let frame_start = std::time::Instant::now();
let _since_last = frame_start.duration_since(self.tool_state.last_update_instant);
self.tool_state.last_update_instant = frame_start;
// Performance log for tracking inter-frame delays (time between Elm update calls).
// Helps debug event loop bottlenecks, main thread blockages, or input lag.
// if since_last.as_secs_f64() > 0.001 {
// log::info!("[perf] inter-frame: {:.1}ms", since_last.as_secs_f64() * 1000.0);
// }
match message {
Message::ToolSelected(tool) => {
log::info!("Tool selected: {:?}", tool);
self.tool_state.active_tool = tool;
}
Message::FgColorChanged(color) => {
self.fg_color = color;
self.active_document_mut().engine.set_color(color);
}
Message::BgColorChanged(color) => {
self.bg_color = color;
}
Message::SwapColors => {
std::mem::swap(&mut self.fg_color, &mut self.bg_color);
let color = self.fg_color;
self.active_document_mut().engine.set_color(color);
}
Message::ColorTabChanged(tab) => {
self.color_tab = tab;
}
Message::CanvasPointerPressed { x, y } => {
// Coordinates are already in canvas-space from the canvas widget
let canvas_x = x;
let canvas_y = y;
self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
let tool = self.tool_state.active_tool;
match tool {
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
// Apply brush parameters before starting stroke
self.apply_brush_params();
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc].engine.set_tool(tool);
self.documents[self.active_doc].engine.set_eraser(tool == Tool::Eraser);
self.documents[self.active_doc].engine.begin_stroke(layer_id, canvas_x, canvas_y);
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, 1.0);
self.tool_state.is_drawing = true;
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
self.tool_state.brush_accumulated_dist = 0.0;
self.tool_state.last_composite_refresh = std::time::Instant::now();
self.refresh_composite_if_needed();
}
Tool::Eyedropper => {
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
if cx < w && cy < h {
let pixels = &self.documents[self.active_doc].composite_raw;
let idx = ((cy * w + cx) * 4) as usize;
if idx + 3 < pixels.len() {
let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
self.fg_color = color;
self.documents[self.active_doc].engine.set_color(color);
}
}
}
Tool::FloodFill => {
let cx = canvas_x as u32;
let cy = canvas_y as u32;
let color = self.fg_color;
self.documents[self.active_doc].engine.flood_fill(cx, cy, color, 32);
self.refresh_composite_if_needed();
}
Tool::Select => {
// Start selection rectangle drag
let sx = canvas_x;
let sy = canvas_y;
return Task::perform(async move {}, move |_| {
Message::SelectionDragStart(sx, sy)
});
}
Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine => {
// Start vector shape drawing
let sx = canvas_x;
let sy = canvas_y;
return Task::perform(async move {}, move |_| {
Message::VectorDrawStart(sx, sy)
});
}
_ => {}
}
}
Message::CanvasPointerMoved { x, y } => {
// Coordinates are already in canvas-space from the canvas widget
self.canvas_cursor_pos = Some((x as u32, y as u32));
if self.tool_state.is_drawing {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
let spacing: f32 = match self.tool_state.active_tool {
Tool::Spray => 2.0_f32,
_ => 1.0_f32,
}.max(1.0);
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
let dx = x - last_x;
let dy = y - last_y;
let dist = (dx * dx + dy * dy).sqrt();
// Use constant pressure for mouse input
// Real tablet pressure comes from tablet_state
let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
if dist >= spacing {
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure);
self.tool_state.last_stroke_pos = Some((x, y));
// Throttle composite refresh to ~60 FPS during strokes.
let now = std::time::Instant::now();
let elapsed = now.duration_since(self.tool_state.last_composite_refresh);
if elapsed.as_secs_f32() >= 0.016 {
self.tool_state.last_composite_refresh = now;
// Performance log for measuring drawing throttle frequency (should fire roughly every 16ms / 60 FPS).
// log::info!("[perf] throttle fire: {:.1}ms since last refresh", elapsed.as_secs_f64() * 1000.0);
self.refresh_composite_if_needed();
}
}
} else {
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0);
self.tool_state.last_stroke_pos = Some((x, y));
}
}
// Handle selection drag
if self.tool_state.active_tool == Tool::Select {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| {
Message::SelectionDragMove(mx, my)
});
}
// Handle vector draw drag
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| {
Message::VectorDrawMove(mx, my)
});
}
}
Message::CanvasPointerReleased => {
if self.tool_state.is_drawing {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc].engine.end_stroke(layer_id);
self.documents[self.active_doc].engine.commit_pending_history();
self.tool_state.is_drawing = false;
self.tool_state.last_stroke_pos = None;
self.refresh_composite_if_needed();
}
// End selection drag
if self.tool_state.active_tool == Tool::Select {
return Task::perform(async {}, |_| Message::SelectionDragEnd);
}
// End vector draw
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
return Task::perform(async {}, |_| Message::VectorDrawEnd);
}
}
Message::CanvasPanZoom { zoom, pan_offset } => {
let doc = &mut self.documents[self.active_doc];
doc.zoom = zoom;
doc.pan_offset = pan_offset;
}
Message::CanvasCursorPos { x, y } => {
self.canvas_cursor_pos = Some((x, y));
}
Message::CanvasZoomSet(z) => {
let doc = &mut self.documents[self.active_doc];
doc.zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
// Keep pan_offset stable so the visible content does not jump
// when zooming via keyboard/menu/slider. The canvas view
// centers on pane+pan_offset, so holding pan_offset keeps the
// same logical content anchored.
}
Message::CanvasZoomRelative(factor) => {
let doc = &mut self.documents[self.active_doc];
doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
// Keep pan_offset — zooming relative to center via keyboard.
}
Message::CanvasSize((w, h)) => {
self.documents[self.active_doc].pane_size = (w, h);
// Keep pan_offset so the canvas stays anchored when the pane
// is resized (e.g. dock splitter dragged).
}
Message::CompositeRefresh | Message::Undo | Message::Redo => {
match message {
Message::Undo => { self.documents[self.active_doc].engine.undo(); }
Message::Redo => { self.documents[self.active_doc].engine.redo(); }
_ => {}
}
self.refresh_composite_if_needed();
}
// ── Layer operations ──────────────────────────
Message::LayerSelect(id) => {
self.documents[self.active_doc].engine.set_active_layer(id);
}
Message::LayerAdd => {
let count = self.documents[self.active_doc].engine.get_layer_count();
self.documents[self.active_doc].engine.add_layer(&format!("Layer {}", count + 1));
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerDelete(id) => {
self.documents[self.active_doc].engine.delete_layer(id);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleVisibility(id) => {
let visible = self.documents[self.active_doc].engine.get_layer_info(id)
.map(|l| !l.visible)
.unwrap_or(true);
self.documents[self.active_doc].engine.set_layer_visible(id, visible);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleLock(id) => {
let locked = self.documents[self.active_doc].engine.get_layer_info(id)
.map(|l| !l.locked)
.unwrap_or(false);
self.documents[self.active_doc].engine.set_layer_locked(id, locked);
}
Message::LayerSetOpacity(id, opacity) => {
self.documents[self.active_doc].engine.set_layer_opacity(id, opacity);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerMergeDown => {
self.documents[self.active_doc].engine.merge_down();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerFlatten => {
self.documents[self.active_doc].engine.flatten_image();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerMoveUp(id) => {
let infos = self.documents[self.active_doc].engine.layer_infos();
if let Some(pos) = infos.iter().position(|l| l.id == id) {
if pos > 0 {
let target_idx = pos - 1;
self.documents[self.active_doc].engine.move_layer(id, target_idx);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
}
Message::LayerMoveDown(id) => {
let infos = self.documents[self.active_doc].engine.layer_infos();
if let Some(pos) = infos.iter().position(|l| l.id == id) {
if pos + 1 < infos.len() {
let target_idx = pos + 1;
self.documents[self.active_doc].engine.move_layer(id, target_idx);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
}
Message::LayerSetBlendMode(id, mode) => {
self.documents[self.active_doc].engine.set_layer_blend_mode(id, mode);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerSetFillOpacity(_id, _opacity) => {
// Fill opacity is not yet exposed via engine API; placeholder for future use.
}
Message::LayerAddGroup => {
let count = self.documents[self.active_doc].engine.get_layer_count();
self.documents[self.active_doc].engine.add_group(&format!("Group {}", count + 1));
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleCollapse(id) => {
self.documents[self.active_doc].engine.toggle_group_collapsed(id);
}
// ── History ───────────────────────────────────
Message::HistoryJumpTo(idx) => {
self.documents[self.active_doc].engine.jump_to_history(idx);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// ── Text tool ─────────────────────────────────
Message::TextSizeChanged(_size) => {
// TODO: apply to active text tool config
}
Message::TextColorChanged(_color) => {
// TODO: apply to active text tool config
}
Message::TextAlignChanged(_alignment) => {
// TODO: apply to active text tool config
}
Message::TextAccept => {
// TODO: commit text changes
}
Message::TextCancel => {
// TODO: cancel text changes
}
// ── Layer styles ──────────────────────────────
Message::LayerStyleToggle(index) => {
// Toggle layer style enabled state.
// BUG FIX: When style doesn't exist, first click creates it ENABLED (not disabled).
// Previous logic used map_or(false, ...) which created disabled styles on first click.
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if (index as usize) < disc_names.len() {
let disc = disc_names[index as usize];
// Find existing style
let existing = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
// Determine new style:
// - Style doesn't exist → create default style with enabled: true
// - Style exists → clone and toggle enabled flag
let new_style = match existing {
None => {
println!("LayerStyleToggle: Style {} not found, creating new", disc);
match disc {
"DropShadow" => LayerStyle::DropShadow {
enabled: true,
opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0,
color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(),
},
"InnerShadow" => LayerStyle::InnerShadow {
enabled: true,
opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0,
color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(),
},
"OuterGlow" => LayerStyle::OuterGlow {
enabled: true,
opacity: 0.75, spread: 0.0, size: 5.0,
color: [255, 255, 190, 255], blend_mode: "Screen".to_string(),
},
"InnerGlow" => LayerStyle::InnerGlow {
enabled: true,
opacity: 0.75, spread: 0.0, size: 5.0,
color: [255, 255, 190, 255], blend_mode: "Screen".to_string(),
},
"BevelEmboss" => LayerStyle::BevelEmboss {
enabled: true,
depth: 1.0, size: 5.0, angle: 120.0, altitude: 30.0,
highlight_opacity: 0.75, shadow_opacity: 0.75,
direction: "Up".to_string(), style: "InnerBevel".to_string(),
technique: "Smooth".to_string(), soften: 0.0,
highlight_blend_mode: "Screen".to_string(),
highlight_color: [255, 255, 255, 255],
shadow_blend_mode: "Multiply".to_string(),
shadow_color: [0, 0, 0, 255],
contour: "Linear".to_string(),
},
"Satin" => LayerStyle::Satin {
enabled: true,
opacity: 0.5, angle: 120.0, distance: 11.0, size: 14.0,
color: [0, 0, 0, 255], invert: true,
},
"ColorOverlay" => LayerStyle::ColorOverlay {
enabled: true,
opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(),
},
"GradientOverlay" => LayerStyle::GradientOverlay {
enabled: true,
opacity: 1.0, blend_mode: "Normal".to_string(),
angle: 90.0, scale: 1.0, gradient_type: 0,
},
"PatternOverlay" => LayerStyle::PatternOverlay {
enabled: true,
opacity: 1.0, blend_mode: "Normal".to_string(),
scale: 1.0, pattern_name: "".to_string(),
},
"Stroke" => LayerStyle::Stroke {
enabled: true,
size: 3.0, position: "Outside".to_string(),
opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(),
},
_ => unreachable!(),
}
}
Some(s) => {
let mut cloned = s.clone();
match &mut cloned {
LayerStyle::DropShadow { enabled, .. }
| LayerStyle::InnerShadow { enabled, .. }
| LayerStyle::OuterGlow { enabled, .. }
| LayerStyle::InnerGlow { enabled, .. }
| LayerStyle::BevelEmboss { enabled, .. }
| LayerStyle::Satin { enabled, .. }
| LayerStyle::ColorOverlay { enabled, .. }
| LayerStyle::GradientOverlay { enabled, .. }
| LayerStyle::PatternOverlay { enabled, .. }
| LayerStyle::Stroke { enabled, .. } => {
*enabled = !*enabled;
println!("LayerStyleToggle: Style {} found, toggling to {}", disc, *enabled);
}
}
cloned
}
};
println!("LayerStyleToggle: Calling update_layer_style with new_style: {:?}", new_style);
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleSelect(index) => {
self.selected_style = index;
}
Message::LayerStyleApply => {
self.active_dialog = ActiveDialog::None;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleCancel => {
self.active_dialog = ActiveDialog::None;
}
Message::LayerStyleAdd => {
// Open layer style add dialog (placeholder)
}
Message::LayerStyleUpdateParam(index, param, value) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut new_style = style.clone();
// Update the specific parameter
match (&mut new_style, param.as_str()) {
(LayerStyle::DropShadow { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::DropShadow { angle, .. }, "angle") => *angle = value,
(LayerStyle::DropShadow { distance, .. }, "distance") => *distance = value,
(LayerStyle::DropShadow { spread, .. }, "spread") => *spread = value,
(LayerStyle::DropShadow { size, .. }, "size") => *size = value,
(LayerStyle::InnerShadow { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::InnerShadow { angle, .. }, "angle") => *angle = value,
(LayerStyle::InnerShadow { distance, .. }, "distance") => *distance = value,
(LayerStyle::InnerShadow { spread, .. }, "spread") => *spread = value,
(LayerStyle::InnerShadow { size, .. }, "size") => *size = value,
(LayerStyle::OuterGlow { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::OuterGlow { spread, .. }, "spread") => *spread = value,
(LayerStyle::OuterGlow { size, .. }, "size") => *size = value,
(LayerStyle::InnerGlow { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::InnerGlow { spread, .. }, "spread") => *spread = value,
(LayerStyle::InnerGlow { size, .. }, "size") => *size = value,
(LayerStyle::BevelEmboss { depth, .. }, "depth") => *depth = value,
(LayerStyle::BevelEmboss { size, .. }, "size") => *size = value,
(LayerStyle::BevelEmboss { angle, .. }, "angle") => *angle = value,
(LayerStyle::BevelEmboss { altitude, .. }, "altitude") => *altitude = value,
(LayerStyle::BevelEmboss { soften, .. }, "soften") => *soften = value,
(LayerStyle::BevelEmboss { highlight_opacity, shadow_opacity, .. }, "opacity") => {
*highlight_opacity = value;
*shadow_opacity = value;
},
(LayerStyle::BevelEmboss { highlight_opacity, .. }, "highlight_opacity") => {
*highlight_opacity = value;
},
(LayerStyle::BevelEmboss { shadow_opacity, .. }, "shadow_opacity") => {
*shadow_opacity = value;
},
(LayerStyle::Satin { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::Satin { angle, .. }, "angle") => *angle = value,
(LayerStyle::Satin { distance, .. }, "distance") => *distance = value,
(LayerStyle::Satin { size, .. }, "size") => *size = value,
(LayerStyle::ColorOverlay { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::GradientOverlay { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::GradientOverlay { angle, .. }, "angle") => *angle = value,
(LayerStyle::GradientOverlay { scale, .. }, "scale") => *scale = value,
(LayerStyle::PatternOverlay { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::PatternOverlay { scale, .. }, "scale") => *scale = value,
(LayerStyle::Stroke { size, .. }, "size") => *size = value,
(LayerStyle::Stroke { opacity, .. }, "opacity") => *opacity = value,
_ => {}
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateBlendMode(index, mode) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut new_style = style.clone();
match &mut new_style {
LayerStyle::DropShadow { blend_mode, .. }
| LayerStyle::InnerShadow { blend_mode, .. }
| LayerStyle::OuterGlow { blend_mode, .. }
| LayerStyle::InnerGlow { blend_mode, .. }
| LayerStyle::ColorOverlay { blend_mode, .. }
| LayerStyle::GradientOverlay { blend_mode, .. }
| LayerStyle::PatternOverlay { blend_mode, .. }
| LayerStyle::Stroke { blend_mode, .. } => *blend_mode = mode,
LayerStyle::BevelEmboss { highlight_blend_mode, .. } => *highlight_blend_mode = mode,
_ => {}
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateColor(index, new_color) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut updated_style = style.clone();
match &mut updated_style {
LayerStyle::DropShadow { color, .. }
| LayerStyle::InnerShadow { color, .. }
| LayerStyle::OuterGlow { color, .. }
| LayerStyle::InnerGlow { color, .. }
| LayerStyle::ColorOverlay { color, .. }
| LayerStyle::Satin { color, .. }
| LayerStyle::Stroke { color, .. } => *color = new_color,
LayerStyle::BevelEmboss { highlight_color, shadow_color, .. } => {
if self.style_color_is_shadow {
*shadow_color = new_color;
} else {
*highlight_color = new_color;
}
}
_ => {}
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style);
}
}
}
// Update the HSL state for the picker
let r = new_color[0] as f32 / 255.0;
let g = new_color[1] as f32 / 255.0;
let b = new_color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) };
let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 };
self.style_color_hsl = (h, s, l);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateString(index, param, value) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s),
("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
)
});
if let Some(style) = existing {
let mut new_style = style.clone();
match (&mut new_style, param.as_str()) {
(LayerStyle::BevelEmboss { style, .. }, "style") => *style = value,
(LayerStyle::BevelEmboss { technique, .. }, "technique") => *technique = value,
(LayerStyle::BevelEmboss { direction, .. }, "direction") => *direction = value,
(LayerStyle::BevelEmboss { highlight_blend_mode, .. }, "highlight_blend") => *highlight_blend_mode = value,
(LayerStyle::BevelEmboss { shadow_blend_mode, .. }, "shadow_blend") => *shadow_blend_mode = value,
(LayerStyle::BevelEmboss { contour, .. }, "contour") => *contour = value,
_ => {}
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateShadowColor(index, new_color) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
});
if let Some(style) = existing {
let mut updated_style = style.clone();
if let LayerStyle::BevelEmboss { shadow_color, .. } = &mut updated_style {
*shadow_color = new_color;
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style);
}
}
}
let r = new_color[0] as f32 / 255.0;
let g = new_color[1] as f32 / 255.0;
let b = new_color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) };
let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 };
self.style_color_hsl = (h, s, l);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateShadowBlend(index, mode) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
});
if let Some(style) = existing {
let mut updated_style = style.clone();
if let LayerStyle::BevelEmboss { shadow_blend_mode, .. } = &mut updated_style {
*shadow_blend_mode = mode;
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateShadowOpacity(index, value) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
});
if let Some(style) = existing {
let mut updated_style = style.clone();
if let LayerStyle::BevelEmboss { shadow_opacity, .. } = &mut updated_style {
*shadow_opacity = value;
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::ShowStyleShadowColorPicker(idx) => {
self.show_style_color_picker = true;
self.style_color_picker_idx = idx;
self.style_color_is_shadow = true;
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 && idx < disc_names.len() {
let disc = disc_names[idx];
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
if let Some(style) = styles.iter().find(|s| {
matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }))
}) {
let color = if let LayerStyle::BevelEmboss { shadow_color, .. } = style {
*shadow_color
} else {
[0, 0, 0, 255]
};
let r = color[0] as f32 / 255.0;
let g = color[1] as f32 / 255.0;
let b = color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) };
let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 };
self.style_color_hsl = (h, s, l);
}
}
return Task::none();
}
Message::ShowStyleColorPicker(idx) => {
self.show_style_color_picker = true;
self.style_color_picker_idx = idx;
self.style_color_is_shadow = false;
// Initialize HSL from current color
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 && idx < disc_names.len() {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc = disc_names[idx];
let color = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
}).map(|s| match s {
LayerStyle::DropShadow { color, .. }
| LayerStyle::InnerShadow { color, .. }
| LayerStyle::OuterGlow { color, .. }
| LayerStyle::InnerGlow { color, .. }
| LayerStyle::ColorOverlay { color, .. }
| LayerStyle::Satin { color, .. }
| LayerStyle::Stroke { color, .. } => *color,
LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color,
_ => [0, 0, 0, 255],
}).unwrap_or([0, 0, 0, 255]);
let r = color[0] as f32 / 255.0;
let g = color[1] as f32 / 255.0;
let b = color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) };
let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 };
self.style_color_hsl = (h, s, l);
}
}
Message::HideStyleColorPicker => {
self.show_style_color_picker = false;
}
Message::OpenLayerStyleDialog => {
self.active_dialog = ActiveDialog::LayerStyleDialog;
self.layer_style_offset = (0.0, 0.0);
self.layer_style_dragging = false;
self.layer_style_drag_start = None;
}
Message::LayerStyleDialogDragStart(_x, _y) => {
// Drag started — begin tracking mouse movement.
// The actual offset computation uses delta from the last position.
self.layer_style_dragging = true;
}
Message::LayerStyleDialogDragMove(x, y) => {
if self.layer_style_dragging {
if let Some((lx, ly)) = self.layer_style_drag_start {
// Compute delta from last position and add to offset
let dx = x - lx;
let dy = y - ly;
self.layer_style_offset.0 += dx;
self.layer_style_offset.1 += dy;
}
self.layer_style_drag_start = Some((x, y));
}
}
Message::LayerStyleDialogDragEnd => {
self.layer_style_dragging = false;
self.layer_style_drag_start = None;
}
// ── Brushes ───────────────────────────────────
Message::BrushStyleSelected(style) => {
log::info!("Brush style selected: {:?}", style);
self.tool_state.brush_style = style;
}
Message::BrushSizeChanged(size) => {
self.tool_state.brush_size = size;
}
Message::BrushOpacityChanged(opacity) => {
self.tool_state.brush_opacity = opacity;
}
Message::BrushHardnessChanged(hardness) => {
self.tool_state.brush_hardness = hardness;
}
// ── Filters ───────────────────────────────────
Message::FilterSelect(filter) => {
self.selected_filter = Some(filter);
self.filter_preview_active = true;
self.filter_params = serde_json::json!({});
self.documents[self.active_doc].engine.filter_preview_begin();
}
Message::FilterParamChanged(key, value) => {
// Update the specific parameter in filter_params
if let serde_json::Value::Object(ref mut map) = self.filter_params {
map.insert(key, value);
}
// Apply preview with updated params
if let Some(filter) = self.selected_filter {
let filter_id = filter_type_to_id(filter);
self.documents[self.active_doc].engine.apply_filter_preview(filter_id, self.filter_params.clone());
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::FilterApply => {
if let Some(filter) = self.selected_filter {
let filter_id = filter_type_to_id(filter);
self.documents[self.active_doc].engine.apply_filter(filter_id, self.filter_params.clone());
self.refresh_composite_if_needed();
self.filter_preview_active = false;
self.selected_filter = None;
self.filter_params = serde_json::json!({});
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::FilterReset => {
if self.filter_preview_active {
self.documents[self.active_doc].engine.filter_preview_restore();
self.refresh_composite_if_needed();
self.filter_preview_active = false;
}
self.selected_filter = None;
self.filter_params = serde_json::json!({});
}
// ── Dialogs ───────────────────────────────────
Message::DialogOpen(dialog) => {
self.active_dialog = dialog;
}
Message::DialogClose => {
self.active_dialog = ActiveDialog::None;
}
Message::DialogCloseSave => {
// TODO: save then close
self.active_dialog = ActiveDialog::None;
}
Message::DialogCloseDiscard => {
self.active_dialog = ActiveDialog::None;
}
// ── New Image Dialog ──────────────────────────
Message::DialogNewImageName(name) => {
self.dialog_new_name = name;
}
Message::DialogNewImageWidth(w) => {
self.dialog_new_width = w;
}
Message::DialogNewImageHeight(h) => {
self.dialog_new_height = h;
}
Message::DialogNewImageTransparent(t) => {
self.dialog_new_transparent = t;
}
Message::DialogNewImagePreset(w, h) => {
self.dialog_new_width = w;
self.dialog_new_height = h;
}
Message::DialogNewImageCreate => {
let w = self.dialog_new_width;
let h = self.dialog_new_height;
let name = self.dialog_new_name.clone();
let transparent = self.dialog_new_transparent;
let mut engine = Engine::new_with_options(&name, w, h, transparent);
engine.pre_tile_all_layers();
let composite_raw = engine.get_composite_pixels();
let cached_layers = engine.layer_infos();
let history_len = engine.history_len();
let cached_history: Vec<(usize, String)> = (0..history_len)
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
.collect();
let history_current = engine.history_current();
self.documents.push(IcedDocument {
engine,
composite_pixels: Arc::new(composite_raw.clone()),
composite_raw,
name,
source_path: None,
modified: false,
zoom: 1.0,
pan_offset: Vector::ZERO,
cached_layers,
cached_history,
history_current,
selection_rect: None,
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
});
self.active_doc = self.documents.len() - 1;
self.active_dialog = ActiveDialog::None;
}
// ── Adjustments Dialog ────────────────────────
Message::AdjBrightness(v) => {
self.dialog_brightness = v;
let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast});
self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjContrast(v) => {
self.dialog_contrast = v;
let params = serde_json::json!({"brightness": self.dialog_brightness, "contrast": v});
self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjHue(v) => {
self.dialog_hue = v;
let params = serde_json::json!({"hue": v, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness});
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjSaturation(v) => {
self.dialog_saturation = v;
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": v, "lightness": self.dialog_lightness});
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjLightness(v) => {
self.dialog_lightness = v;
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": v});
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::AdjApply => {
// Apply the adjustment permanently (not just preview)
let params = match &self.active_dialog {
ActiveDialog::BrightnessContrast => {
serde_json::json!({"brightness": self.dialog_brightness, "contrast": self.dialog_contrast})
}
ActiveDialog::HueSaturation => {
serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness})
}
_ => serde_json::json!({}),
};
// First restore the preview, then apply permanently
self.documents[self.active_doc].engine.filter_preview_restore();
let filter_id = match &self.active_dialog {
ActiveDialog::BrightnessContrast => "brightness_contrast",
ActiveDialog::HueSaturation => "hue_saturation",
_ => "",
};
if !filter_id.is_empty() {
self.documents[self.active_doc].engine.apply_filter(filter_id, params);
}
self.refresh_composite_if_needed();
self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// ── Selection Dialog ──────────────────────────
Message::SelectionOpValue(v) => {
self.dialog_selection_value = v;
}
Message::SelectionOpApply => {
let v = self.dialog_selection_value as u32;
match &self.active_dialog {
ActiveDialog::SelectionOp(op) => {
match *op {
"Grow" => self.documents[self.active_doc].engine.selection_grow(v),
"Shrink" => self.documents[self.active_doc].engine.selection_shrink(v),
"Feather" => self.documents[self.active_doc].engine.selection_feather(v as f32),
_ => {}
}
}
_ => {}
}
self.refresh_composite_if_needed();
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.take() {
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.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
}
// ── Vector tools ──────────────────────────────
Message::VectorDrawStart(x, y) => {
self.documents[self.active_doc].vector_draw = Some(((x, y), (x, y)));
}
Message::VectorDrawMove(x, y) => {
if let Some((start, _)) = self.documents[self.active_doc].vector_draw {
self.documents[self.active_doc].vector_draw = Some((start, (x, y)));
}
}
Message::VectorDrawEnd => {
if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].vector_draw.take() {
let engine = &mut self.documents[self.active_doc].engine;
let color = self.fg_color;
match self.tool_state.active_tool {
Tool::VectorRect => {
let shape = hcie_engine_api::VectorShape::Rect {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
fill: true,
fill_color: color,
stroke: 2.0,
color,
radius: 0.0,
angle: 0.0,
opacity: 1.0,
hardness: 0.5,
};
engine.add_vector_shape(shape);
}
Tool::VectorCircle => {
let shape = hcie_engine_api::VectorShape::Circle {
x1: x0.min(x1),
y1: y0.min(y1),
x2: x0.max(x1),
y2: y0.max(y1),
fill: true,
fill_color: color,
stroke: 2.0,
color,
angle: 0.0,
opacity: 1.0,
hardness: 0.5,
};
engine.add_vector_shape(shape);
}
Tool::VectorLine => {
let shape = hcie_engine_api::VectorShape::Line {
x1: x0,
y1: y0,
x2: x1,
y2: y1,
stroke: 2.0,
color,
angle: 0.0,
cap_start: hcie_engine_api::LineCap::Round,
cap_end: hcie_engine_api::LineCap::Round,
opacity: 1.0,
hardness: 0.5,
};
engine.add_vector_shape(shape);
}
_ => {}
}
self.refresh_composite_if_needed();
}
}
// ── AI Chat ───────────────────────────────────
Message::AiChatInput(_text) => {
// Handled by the chat panel directly
}
Message::AiChatSend => {
// Placeholder: would send to LLM
log::info!("AI chat send not yet implemented");
}
Message::AiChatClear => {
// Handled by the chat panel directly
}
// ── Clipboard ─────────────────────────────────
Message::CopyImage => {
let doc = &self.documents[self.active_doc];
let w = doc.engine.canvas_width();
let h = doc.engine.canvas_height();
let pixels = doc.composite_raw.clone();
return Task::perform(
async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) },
Message::ImageCopied,
);
}
Message::PasteImage => {
return Task::perform(
async { crate::io::clipboard::paste_image_from_clipboard() },
Message::ImagePasted,
);
}
Message::CopyText(text) => {
let text = text.clone();
return Task::perform(
async move { crate::io::clipboard::copy_text_to_clipboard(&text) },
|_| Message::NoOp,
);
}
Message::PasteText => {
return Task::perform(
async { crate::io::clipboard::paste_text_from_clipboard() },
Message::ClipboardResult,
);
}
Message::ClipboardResult(Ok(Some(text))) => {
log::info!("Pasted text: {}", &text[..text.len().min(50)]);
}
Message::ClipboardResult(Ok(None)) => {
log::info!("Clipboard is empty");
}
Message::ClipboardResult(Err(e)) => {
log::error!("Clipboard error: {}", e);
}
Message::ImageCopied(Ok(())) => {
log::info!("Image copied to clipboard");
}
Message::ImageCopied(Err(e)) => {
log::error!("Failed to copy image: {}", e);
}
Message::ImagePasted(Ok(Some((pixels, w, h)))) => {
log::info!("Pasted image: {}x{}", w, h);
let engine = &mut self.documents[self.active_doc].engine;
engine.create_layer_from_data("Pasted", pixels, w, h);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::ImagePasted(Ok(None)) => {
log::info!("No image in clipboard");
}
Message::ImagePasted(Err(e)) => {
log::error!("Failed to paste image: {}", e);
}
// ── File I/O (with rfd) ───────────────────────
Message::OpenFileRfd => {
return Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Open Image")
.add_filter("All Supported Types", &[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
"hcie", "psd", "kra",
])
.add_filter("Images", &[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
])
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
},
Message::FileDialogClosed,
);
}
Message::SaveFileAs => {
return Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Save As")
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.add_filter("PNG", &["png"])
.add_filter("JPEG", &["jpg", "jpeg"])
.add_filter("WebP", &["webp"])
.add_filter("AVIF", &["avif"])
.add_filter("BMP", &["bmp"])
.add_filter("GIF", &["gif"])
.add_filter("TIFF", &["tiff", "tif"])
.add_filter("TGA", &["tga"])
.add_filter("OpenEXR", &["exr"])
.add_filter("All Supported Types", &[
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
"avif", "tga", "exr", "psd", "kra", "hcie",
])
.save_file()
.await
.map(|handle| handle.path().to_path_buf())
},
Message::FileDialogClosed,
);
}
Message::FileDialogClosed(Some(path)) => {
let path_str = path.to_string_lossy().to_string();
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
if self.active_dialog == ActiveDialog::None {
// Open file — route multi-layer formats to dedicated importers
let engine = &mut self.documents[self.active_doc].engine;
let result = match ext.as_str() {
"psd" => engine.import_psd(&path_str),
"kra" => engine.import_kra(&path_str),
"hcie" => engine.load_native(&path_str),
_ => engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path.clone());
self.add_recent_file(&path);
self.documents[self.active_doc].full_upload.replace(true);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => log::error!("Failed to open: {}", e),
}
} else {
// Save file — route multi-layer formats to dedicated exporters
let engine = &self.documents[self.active_doc].engine;
let result = match ext.as_str() {
"hcie" => engine.save_native(&path_str),
"psd" => engine.export_psd(&path_str),
"kra" => engine.export_kra(&path_str),
_ => engine.save_as(&path_str, &ext),
};
match result {
Ok(()) => log::info!("Saved to {}", path_str),
Err(e) => log::error!("Failed to save: {}", e),
}
self.active_dialog = ActiveDialog::None;
}
}
Message::FileDialogClosed(None) => {
// User cancelled
}
// ── Dock Layout ───────────────────────────────
Message::PaneClicked(pane) => {
self.dock.focus(pane);
}
Message::PaneDragged(drag_event) => {
if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = drag_event {
if let iced::widget::pane_grid::Target::Pane(target_pane, _) = target {
self.dock.pane_grid.swap(pane, target_pane);
}
}
}
Message::PaneResized(resize_event) => {
self.dock.pane_grid.resize(resize_event.split, resize_event.ratio);
}
Message::PaneClose(pane) => {
// Don't close canvas panes
if let Some(PaneType::Canvas) = self.dock.pane_type(pane) {
return Task::none();
}
self.dock.close_pane(pane);
}
Message::PaneMaximize(pane) => {
self.dock.toggle_maximize(pane);
}
// ── File I/O ─────────────────────────────────
Message::NewDocument(w, h) => {
let mut engine = Engine::new(w, h);
engine.pre_tile_all_layers();
let composite_raw = engine.get_composite_pixels();
let cached_layers = engine.layer_infos();
let history_len = engine.history_len();
let cached_history: Vec<(usize, String)> = (0..history_len)
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
.collect();
let history_current = engine.history_current();
self.documents.push(IcedDocument {
engine,
composite_pixels: Arc::new(composite_raw.clone()),
composite_raw,
name: "Untitled".to_string(),
source_path: None,
modified: false,
zoom: 1.0,
pan_offset: Vector::ZERO,
cached_layers,
cached_history,
history_current,
selection_rect: None,
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
});
}
Message::DocSwitch(idx) => {
if idx < self.documents.len() {
self.active_doc = idx;
}
}
Message::OpenFile => {
return Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Open Image")
.add_filter("All Supported Types", &[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
"hcie", "psd", "kra",
])
.add_filter("Images", &[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
])
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
},
Message::FileDialogClosed,
);
}
Message::FileOpened(Ok(path)) => {
log::info!("File opened: {:?}", path);
}
Message::FileOpened(Err(e)) => {
log::error!("Failed to open file: {}", e);
}
Message::SaveFile => {
return Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Save As")
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.add_filter("PNG", &["png"])
.add_filter("JPEG", &["jpg", "jpeg"])
.add_filter("WebP", &["webp"])
.add_filter("AVIF", &["avif"])
.add_filter("BMP", &["bmp"])
.add_filter("GIF", &["gif"])
.add_filter("TIFF", &["tiff", "tif"])
.add_filter("TGA", &["tga"])
.add_filter("OpenEXR", &["exr"])
.add_filter("All Supported Types", &[
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
"avif", "tga", "exr", "psd", "kra", "hcie",
])
.save_file()
.await
.map(|handle| handle.path().to_path_buf())
},
Message::FileDialogClosed,
);
}
// ── Menu ─────────────────────────────────────
Message::MenuOpen(idx) => {
if self.active_menu == Some(idx) {
self.active_menu = None;
} else {
self.active_menu = Some(idx);
}
}
Message::MenuClose => {
if self.active_menu.is_some() {
self.active_menu = None;
} else if self.active_dialog != ActiveDialog::None {
self.active_dialog = ActiveDialog::None;
}
}
Message::OpenRecentFile(idx) => {
self.active_menu = None;
// Clone the path to avoid borrow conflict
let path_str = self.recent_files.get(idx).map(|e| e.path.clone());
if let Some(path_str) = path_str {
let path = std::path::PathBuf::from(&path_str);
if path.exists() {
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => self.documents[self.active_doc].engine.import_psd(&path_str),
"kra" => self.documents[self.active_doc].engine.import_kra(&path_str),
"hcie" => self.documents[self.active_doc].engine.load_native(&path_str),
_ => self.documents[self.active_doc].engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path.clone());
self.add_recent_file(&path);
let composite = self.documents[self.active_doc].engine.get_composite_pixels();
self.documents[self.active_doc].composite_raw = composite;
self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(self.documents[self.active_doc].composite_raw.clone());
self.documents[self.active_doc].full_upload.replace(true);
self.documents[self.active_doc].render_generation = self.documents[self.active_doc].render_generation.wrapping_add(1);
self.documents[self.active_doc].cached_layers = self.documents[self.active_doc].engine.layer_infos();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => log::error!("Failed to open recent file: {}", e),
}
} else {
self.recent_files.retain(|e| e.path != path_str);
}
}
}
Message::ClearRecentFiles => {
self.active_menu = None;
self.recent_files.clear();
save_recent_files(&self.recent_files);
}
Message::MenuAction(menu_idx, item_idx) => {
self.active_menu = None;
match (menu_idx, item_idx) {
(0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open
(0, _) if item_idx >= 2 => {
let recent_count = self.recent_files.len().min(10);
let static_base = if recent_count > 0 { 4 + recent_count + 3 } else { 2 };
if item_idx >= static_base {
match item_idx - static_base {
0 => return Task::perform(async {}, |_| Message::SaveFileAs),
1 => return Task::perform(async {}, |_| Message::SaveFileAs),
6 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG
7 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG
9 => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)),
10 => {
if let Some(id) = self.window_id {
return iced::window::close(id);
}
}
_ => {}
}
}
}
// ── Edit menu (1) ──
(1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo
(1, 1) => return Task::perform(async {}, |_| Message::Redo), // Redo
// (1, 2) = separator
(1, 3) => return Task::perform(async {}, |_| Message::CopyImage), // Cut (copy for now)
(1, 4) => return Task::perform(async {}, |_| Message::CopyImage), // Copy
(1, 5) => return Task::perform(async {}, |_| Message::PasteImage), // Paste
// (1, 6) = separator
(1, 7) => { // Select All
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
}
(1, 8) => { self.documents[self.active_doc].selection_rect = None; } // Deselect
// (1, 9) = Invert Selection — TODO
// (1, 10) = separator
// (1, 11) = Fill — TODO
// ── Tools menu (2) ──
(2, 0) => self.tool_state.active_tool = Tool::Move,
(2, 1) => self.tool_state.active_tool = Tool::VectorSelect,
// (2, 2) = separator
(2, 3) => self.tool_state.active_tool = Tool::Select,
(2, 4) => self.tool_state.active_tool = Tool::Lasso,
(2, 5) => self.tool_state.active_tool = Tool::PolygonSelect,
(2, 6) => self.tool_state.active_tool = Tool::MagicWand,
// (2, 7) = separator
(2, 8) => self.tool_state.active_tool = Tool::Crop,
(2, 9) => self.tool_state.active_tool = Tool::Eyedropper,
// (2, 10) = separator
(2, 11) => self.tool_state.active_tool = Tool::Brush,
(2, 12) => self.tool_state.active_tool = Tool::Pen,
(2, 13) => self.tool_state.active_tool = Tool::Spray,
(2, 14) => self.tool_state.active_tool = Tool::Eraser,
// (2, 15) = separator
(2, 16) => self.tool_state.active_tool = Tool::FloodFill,
(2, 17) => self.tool_state.active_tool = Tool::Gradient,
// (2, 18) = separator
(2, 19) => self.tool_state.active_tool = Tool::Text,
// (2, 20) = separator
(2, 21) => self.tool_state.active_tool = Tool::VectorLine,
(2, 22) => self.tool_state.active_tool = Tool::VectorRect,
(2, 23) => self.tool_state.active_tool = Tool::VectorCircle,
// (2, 24) = separator
// (2, 25) = Reset Tool — TODO
// ── Image menu (3) ──
// (3, 0) = Canvas Size — TODO
// (3, 1) = Image Size — TODO
// (3, 2) = separator
// (3, 5) = Rotate 180 — TODO
// (3, 6) = separator
// (3, 9) = separator
// (3, 10) = Crop to Selection — TODO
// ── Layer menu (4) ──
(4, 0) => return Task::perform(async {}, |_| Message::LayerAdd), // New Layer
// (4, 1) = Duplicate Layer — TODO
(4, 2) => { // Delete Layer
let id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc].engine.delete_layer(id);
}
// (4, 3) = separator
(4, 4) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down
(4, 5) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image
// (4, 6) = separator
(4, 7) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Styles
// (4, 10) = separator
// (4, 11) = Align Left — TODO
// (4, 12) = Align Center — TODO
// (4, 13) = Align Right — TODO
// ── Filter menu (5) ──
// (5, 0) = ─ Blur (disabled)
(5, 1) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::BoxBlur)),
(5, 2) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::GaussianBlur)),
(5, 3) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::MotionBlur)),
// (5, 4) = ─ Sharpen (disabled)
(5, 5) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::UnsharpMask)),
// (5, 6) = ─ Pixelate (disabled)
(5, 7) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Mosaic)),
(5, 8) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Crystallize)),
// (5, 9) = ─ Distort (disabled)
(5, 10) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Pinch)),
(5, 11) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Twirl)),
// (5, 12) = ─ Stylize (disabled)
(5, 13) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::OilPaint)),
// (5, 14) = ─ Color & Light (disabled)
(5, 15) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Levels)),
(5, 16) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Vibrance)),
(5, 17) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Exposure)),
(5, 18) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Posterize)),
(5, 19) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Threshold)),
// (5, 20) = ─ Restore (disabled)
(5, 21) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Dehaze)),
// ── Select menu (6) ──
(6, 0) => { // Select All
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
}
(6, 1) => { self.documents[self.active_doc].selection_rect = None; } // Deselect
// (6, 2) = Invert Selection — TODO
// (6, 3) = separator
(6, 4) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow
(6, 5) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } // Shrink
(6, 6) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } // Feather
// ── View menu (7) ──
(7, 0) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)), // Zoom In
(7, 1) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)), // Zoom Out
(7, 2) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // Zoom Reset
// (7, 3) = separator
(7, 4) => { // Fit to Window
// Will be handled in view() with access to window size
self.documents[self.active_doc].zoom = 1.0;
}
(7, 5) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // 100%
(7, 6) => return Task::perform(async {}, |_| Message::CanvasZoomSet(2.0)), // 200%
// (7, 7) = separator
// (7, 8) = Debug Mode — TODO
// ── Window menu (8) ──
(8, 0) => self.dock.reopen_pane(PaneType::Layers),
(8, 1) => self.dock.reopen_pane(PaneType::History),
(8, 2) => self.dock.reopen_pane(PaneType::Brushes),
(8, 3) => self.dock.reopen_pane(PaneType::Filters),
(8, 4) => self.dock.reopen_pane(PaneType::ColorPicker),
(8, 5) => self.dock.reopen_pane(PaneType::Properties),
(8, 6) => self.dock.reopen_pane(PaneType::AiChat),
(8, 7) => self.dock.reopen_pane(PaneType::Geometry),
(8, 8) => self.dock.reopen_pane(PaneType::Script),
(8, 9) => self.dock.reopen_pane(PaneType::LayerDetails),
// ── Help menu (9) ──
// (9, 0) = About — TODO
// (9, 1) = Documentation — TODO
// (9, 2) = License — TODO
// Ignore separators and unimplemented items
_ => {}
}
}
// ── Window controls ──────────────────────────
Message::WindowDrag => {
if let Some(id) = self.window_id {
return iced::window::drag(id);
}
}
Message::WindowMinimize => {
if let Some(id) = self.window_id {
return iced::window::minimize(id, true);
}
}
Message::WindowMaximize => {
if let Some(id) = self.window_id {
return iced::window::toggle_maximize(id);
}
}
Message::WindowClose => {
if let Some(id) = self.window_id {
return iced::window::close(id);
}
}
Message::WindowId(id) => {
self.window_id = Some(id);
}
Message::NoOp => {}
}
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 doc = &self.documents[self.active_doc];
let colors = self.theme_state.colors();
// Title bar (now includes menu items and window controls)
let title_bar = panels::title_bar::view(
&doc.name,
doc.zoom,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
colors,
self.active_menu,
);
// Toolbox — fixed 36px strip on the left edge (outside dock)
let toolbox = crate::sidebar::view(
&self.tool_state,
&self.fg_color,
&self.bg_color,
colors,
);
// Dock grid (without toolbox)
let dock_content = crate::dock::view::dock_view(&self.dock, self);
// Main area: toolbox + dock side by side with app background.
// A small gap between the fixed toolbox and the dock makes the
// workspace boundary visible (same dark bg_app color shows through).
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,
main_area,
status_bar,
]
.width(Length::Fill)
.height(Length::Fill);
let content = container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
..Default::default()
});
// Dialog overlay
let dialog_overlay: Element<'_, Message> = match &self.active_dialog {
ActiveDialog::None => text("").into(),
ActiveDialog::NewImage => dialogs::new_image::view(
&self.dialog_new_name,
self.dialog_new_width,
self.dialog_new_height,
self.dialog_new_transparent,
),
ActiveDialog::BrightnessContrast => dialogs::adjustments::brightness_contrast_view(
self.dialog_brightness,
self.dialog_contrast,
),
ActiveDialog::HueSaturation => dialogs::adjustments::hsl_view(
self.dialog_hue,
self.dialog_saturation,
self.dialog_lightness,
),
ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name),
ActiveDialog::SelectionOp(op) => dialogs::confirm::selection_op_view(
op,
self.dialog_selection_value,
1.0,
100.0,
),
ActiveDialog::LayerStyleDialog => {
let doc = &self.documents[self.active_doc];
let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id());
let styles: &'static [hcie_engine_api::LayerStyle] = Box::leak(styles.into_boxed_slice());
crate::panels::layer_styles::view(
styles,
self.selected_style,
colors,
self.show_style_color_picker,
self.style_color_picker_idx,
self.style_color_hsl,
self.layer_style_offset,
)
}
};
// Stack main content with overlays (dialog + menu dropdown).
// `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();
if has_dialog || has_menu {
let mut stack = iced::widget::Stack::new().push(content);
if has_dialog {
stack = stack.push(dialog_overlay);
}
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files) {
stack = stack.push(menu_overlay);
}
let _elapsed = view_start.elapsed();
// Performance log measuring Elm view reconstruction time.
// useful to track widget layout allocation and view model reconstruction times.
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
stack.width(Length::Fill).height(Length::Fill).into()
} else {
let _elapsed = view_start.elapsed();
// Performance log measuring Elm view reconstruction time.
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
content.into()
}
}
// ── Theme & Subscription ────────────────────────────
/// Return the application theme.
pub fn theme(&self) -> Theme {
Theme::Dark
}
/// Subscriptions — keyboard events for shortcuts.
///
/// Handles common keyboard shortcuts:
/// - Ctrl+Z: Undo
/// - Ctrl+Y / Ctrl+Shift+Z: Redo
/// - Ctrl+S: Save
/// - Ctrl+N: New
/// - Ctrl+O: Open
/// - Ctrl+C: Copy
/// - Ctrl+V: Paste
/// - Escape: Close dialog
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 {
// 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 => {
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::SaveFileAs)
}
// Ctrl+N = New
iced::keyboard::Key::Character(ref c) if c.as_str() == "n" && ctrl => {
Some(Message::DialogOpen(ActiveDialog::NewImage))
}
// Ctrl+O = Open
iced::keyboard::Key::Character(ref c) if c.as_str() == "o" && ctrl => {
Some(Message::OpenFileRfd)
}
// Ctrl+C = Copy
iced::keyboard::Key::Character(ref c) if c.as_str() == "c" && ctrl && !shift => {
Some(Message::CopyImage)
}
// Ctrl+V = Paste
iced::keyboard::Key::Character(ref c) if c.as_str() == "v" && ctrl && !shift => {
Some(Message::PasteImage)
}
// Ctrl+X = Cut
iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => {
Some(Message::CopyImage) // TODO: cut
}
// Escape = close menu, cancel dialog, or deselect
iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => {
Some(Message::MenuClose)
}
_ => None,
}
});
// Mouse events for dialog dragging
let mouse = iced::event::listen_with(|event, _status, _id| {
if let iced::Event::Mouse(mouse_event) = event {
match mouse_event {
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => {
// Drag start is handled by mouse_area on_press
None
}
iced::mouse::Event::CursorMoved { position } => {
Some(Message::LayerStyleDialogDragMove(position.x, position.y))
}
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
Some(Message::LayerStyleDialogDragEnd)
}
_ => None,
}
} else {
None
}
});
iced::Subscription::batch(vec![keyboard, mouse])
}
}