diff --git a/.mimocode/plans/1783877383042-clever-nebula.md b/.mimocode/plans/1783877383042-clever-nebula.md new file mode 100644 index 0000000..eebc252 --- /dev/null +++ b/.mimocode/plans/1783877383042-clever-nebula.md @@ -0,0 +1,64 @@ +# Plan: Iterate Layers Panel Visual Design + +## Goal +Iteratively improve the iced layers panel to match the Photoshop reference screenshots. Test, take screenshots, compare, and fix until it looks right. + +## Visual Issues to Fix (from code analysis) + +### 1. Active Layer Row Styling +**Current**: `active_item_bg` uses `bg_active` (#404040) with 1px accent border +**Photoshop**: Subtle blue-tinted background fill, no prominent border +**Fix**: Change active row to use `accent` color at low alpha (0.15-0.2) for background, remove or淡化 the border + +### 2. Layer Row Spacing +**Current**: `spacing(1)` between rows — too tight +**Photoshop**: ~4px spacing between rows +**Fix**: Increase to `spacing(2)` or `spacing(3)` + +### 3. Visibility Icon +**Current**: `◉`/`○` Unicode characters +**Photoshop**: Eye icon (👁) +**Fix**: Use eye emoji or simpler toggle indicator + +### 4. Thumbnail Border +**Current**: No border on thumbnails +**Photoshop**: 1px subtle border around thumbnails +**Fix**: Add container with border around thumbnail image + +### 5. Layer Name Font +**Current**: Monospace font for active layer +**Photoshop**: Proportional font for all layers +**Fix**: Remove `.font(iced::Font::MONOSPACE)` from active layer name + +### 6. Controls Layout +**Current**: Blend mode, opacity, lock in separate rows +**Photoshop**: More compact, blend mode + opacity on same row +**Fix**: Reorganize controls to be more compact + +### 7. Button Styling +**Current**: Default iced button styling +**Photoshop**: Flat buttons with subtle borders, no background +**Fix**: Add button style function for flat Photoshop-like buttons + +### 8. Panel Header +**Current**: "Layers" text with monospace font +**Photoshop**: Panel title with specific styling +**Fix**: Adjust header to match Photoshop's panel title style + +## Implementation Steps + +1. **Fix active row styling** — Change background to accent color at low alpha +2. **Increase row spacing** — Change from `spacing(1)` to `spacing(2)` +3. **Fix visibility icon** — Use eye emoji or simpler indicator +4. **Add thumbnail border** — Wrap thumbnail in container with border +5. **Fix font** — Remove monospace from active layer name +6. **Reorganize controls** — Make more compact +7. **Add button styles** — Create flat Photoshop-like button style +8. **Fix panel header** — Match Photoshop's title style + +## Verification +1. Build and run the app +2. Take screenshot of Layers panel +3. Compare with Photoshop reference +4. Identify remaining differences +5. Repeat until satisfied diff --git a/Cargo.lock b/Cargo.lock index 8c2fce4..9849dfe 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2794,6 +2794,7 @@ dependencies = [ "arboard", "bytemuck", "bytes", + "dirs 5.0.1", "env_logger", "evdev", "hcie-engine-api", @@ -2802,6 +2803,7 @@ dependencies = [ "image 0.25.10", "log", "rfd", + "serde", "serde_json", ] diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index bc0374c..4120726 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -1720,6 +1720,16 @@ impl Engine { layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); layer.dirty = true; self.document.composite_dirty = true; + // Mark the full canvas as dirty so the next render recomposites. + // Without this, dirty_bounds stays None and partial_composite forces + // a full-canvas recomposite anyway — but other property ops set this + // explicitly so we should too for consistency and correctness. + let w = self.document.canvas_width; + let h = self.document.canvas_height; + self.document.dirty_bounds = Some([0, 0, w, h]); + // Invalidate the below-layer composite cache since this layer's + // pixels changed via the effects pipeline. + self.below_cache_dirty = true; } } @@ -1836,6 +1846,10 @@ impl Engine { } if did_remove { self.document.composite_dirty = true; + let w = self.document.canvas_width; + let h = self.document.canvas_height; + self.document.dirty_bounds = Some([0, 0, w, h]); + self.below_cache_dirty = true; } if empty_styles { self.raw_pixel_backup.remove(&id); diff --git a/hcie-engine-api/src/partial_composite.rs b/hcie-engine-api/src/partial_composite.rs index 2e68f7d..4958829 100644 --- a/hcie-engine-api/src/partial_composite.rs +++ b/hcie-engine-api/src/partial_composite.rs @@ -233,6 +233,10 @@ impl Engine { // Pass 2: Apply layer effects. Restores from backup first so that each // slider edit always applies on top of the original untouched pixels. + // CRITICAL: Do NOT modify layer.pixels with effects output. layer.pixels + // must always contain the raw drawing data so that new strokes are drawn + // on top of clean pixels (not on effects-applied pixels). The composite + // uses effects_cache for layers with effects, not layer.pixels. for layer in &mut self.document.layers { if layer.effects.is_empty() && layer.styles.is_empty() { continue; } if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) { @@ -258,7 +262,9 @@ impl Engine { width: layer.width, height: layer.height, }); - layer.pixels = processed; + // DON'T: layer.pixels = processed; + // layer.pixels stays as raw drawing data. The composite pipeline + // in tiled.rs reads from effects_cache when it exists. layer.effects_dirty.store(false, std::sync::atomic::Ordering::Release); layer.dirty = true; } diff --git a/hcie-engine-api/src/stroke_brush.rs b/hcie-engine-api/src/stroke_brush.rs index 15f4228..1ea51be 100644 --- a/hcie-engine-api/src/stroke_brush.rs +++ b/hcie-engine-api/src/stroke_brush.rs @@ -127,9 +127,9 @@ impl Engine { tip.density, ); layer.dirty = true; - // Effects are expensive (33MB clone + apply_layer_effects on 4K). - // Only mark the layer as needing an effects pass if it actually - // has any effects or editable styles. + // Mark effects_dirty so the effects pipeline re-runs after the + // stroke ends. This is safe because apply_effects_and_sync_tiles + // no longer modifies layer.pixels — it only updates effects_cache. if !layer.effects.is_empty() || !layer.styles.is_empty() { layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); } @@ -166,8 +166,6 @@ impl Engine { self.stroke_before_buf.as_deref(), ); layer.dirty = true; - // Avoid triggering the expensive effects pass for layers that - // have no effects/styles. if !layer.effects.is_empty() || !layer.styles.is_empty() { layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); } @@ -200,8 +198,6 @@ impl Engine { let px_color = [color[0], color[1], color[2], (color[3] as f32 * tip.opacity * pressure).round() as u8]; draw_line(layer, x0, y0, x1, y1, px_color, tip.size, mask_ref); layer.dirty = true; - // Only request an effects re-render if the layer actually has - // effects or editable styles. if !layer.effects.is_empty() || !layer.styles.is_empty() { layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); } diff --git a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml index e8f8c59..65b51bd 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml +++ b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml @@ -18,9 +18,11 @@ iced = { workspace = true } env_logger = { workspace = true } log = { workspace = true } image = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } rfd = { workspace = true } arboard = { workspace = true } bytes = "1.12" bytemuck = { version = "1", features = ["derive"] } evdev = { version = "0.12", optional = true } +dirs = "5.0" diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index a57d249..509b214 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -17,11 +17,44 @@ use crate::dock::state::{DockState, PaneType}; use crate::io::tablet::TabletState; use crate::panels; use crate::theme::ThemeState; -use hcie_engine_api::{BrushTip, BrushStyle, Engine, FilterType, Tool, ZOOM_MAX, ZOOM_MIN}; +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 { + 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)] @@ -32,6 +65,7 @@ pub enum ActiveDialog { HueSaturation, CloseConfirm, SelectionOp(&'static str), + LayerStyleDialog, } /// Top-level application state. @@ -83,6 +117,33 @@ pub struct HcieIcedApp { pub theme_state: ThemeState, /// Currently open menu index (None = no menu open). pub active_menu: Option, + /// Recently opened files (most recent first, max 20). + pub recent_files: Vec, + /// 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), + /// 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. @@ -183,6 +244,7 @@ pub enum Message { FgColorChanged([u8; 4]), BgColorChanged([u8; 4]), SwapColors, + ColorTabChanged(usize), // ── Canvas interaction ────────────────────────────── CanvasPointerPressed { x: f32, y: f32 }, @@ -210,6 +272,10 @@ pub enum Message { LayerFlatten, LayerMoveUp(u64), LayerMoveDown(u64), + LayerSetBlendMode(u64, BlendMode), + LayerSetFillOpacity(u64, f32), + LayerAddGroup, + LayerToggleCollapse(u64), // ── History ───────────────────────────────────────── HistoryJumpTo(i32), @@ -223,6 +289,18 @@ pub enum Message { // ── Layer styles ──────────────────────────────────── LayerStyleToggle(u32), + LayerStyleSelect(usize), + LayerStyleApply, + LayerStyleCancel, + LayerStyleUpdateParam(usize, String, f32), + LayerStyleUpdateBlendMode(usize, String), + LayerStyleUpdateColor(usize, [u8; 4]), + ShowStyleColorPicker(usize), + HideStyleColorPicker, + OpenLayerStyleDialog, + LayerStyleDialogDragStart(f32, f32), + LayerStyleDialogDragMove(f32, f32), + LayerStyleDialogDragEnd, LayerStyleAdd, // ── Brushes ───────────────────────────────────────── @@ -310,6 +388,8 @@ pub enum Message { MenuOpen(usize), MenuClose, MenuAction(usize, usize), // (menu_index, item_index) + OpenRecentFile(usize), // index into recent_files + ClearRecentFiles, // ── Window ─────────────────────────────────────────── WindowDrag, @@ -446,20 +526,59 @@ impl HcieIcedApp { 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), + 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 + // 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(); - match app.documents[0].engine.open_image(&path_str) { + 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); + 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. + // 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); - app.refresh_composite_if_needed(); + // 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); @@ -515,6 +634,29 @@ impl HcieIcedApp { + /// 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. @@ -524,60 +666,23 @@ impl HcieIcedApp { let doc = &mut self.documents[self.active_doc]; if doc.engine.is_composite_dirty() { - let t0 = std::time::Instant::now(); - let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region(); - let t1 = std::time::Instant::now(); - let _engine_ms = t1.duration_since(t0).as_secs_f64() * 1000.0; - - if !buf_ptr.is_null() && buf_size > 0 { - let region = region_result.unwrap_or([0, 0, doc.engine.canvas_width(), doc.engine.canvas_height()]); - let _rw = (region[2] - region[0]) as usize; - let _rh = (region[3] - region[1]) as usize; - - // Ensure local buffer matches engine dimensions. - if doc.composite_raw.len() != buf_size { - doc.composite_raw = vec![0u8; buf_size]; - } - - // Copy the full composite scratch buffer into our raw buffer. - // Since buf_ptr points to the persistent composite_scratch which contains - // the full valid canvas, a full CPU copy (<0.1ms) is extremely cheap and - // ensures we have no gaps/missing pixels due to dirty bounds mismatches. - unsafe { - std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_raw.as_mut_ptr(), buf_size); - } - - // Mutate the shared composite pixels in-place if no other references exist, - // completely avoiding the 33MB cloning overhead on the UI thread. + // 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 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) { - unsafe { - std::ptr::copy_nonoverlapping(buf_ptr, pixels.as_mut_ptr(), buf_size); - } + pixels.resize(doc.composite_raw.len(), 0); + pixels.copy_from_slice(&doc.composite_raw); } else { doc.composite_pixels = Arc::new(doc.composite_raw.clone()); } - - if region_result.is_none() { - doc.full_upload.replace(true); - doc.dirty_region.replace(None); - } else if !*doc.full_upload.borrow() { - let new_region = union_regions(*doc.dirty_region.borrow(), region); - doc.dirty_region.replace(Some(new_region)); - } + doc.full_upload.replace(true); + doc.dirty_region.replace(None); doc.render_generation = doc.render_generation.wrapping_add(1); - - let t2 = std::time::Instant::now(); - let _total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0; - let _copy_ms = t2.duration_since(t1).as_secs_f64() * 1000.0; - // Performance log for engine rendering duration + CPU memory copy times. - // useful to check if dirty-region composite calculations start slowing down. - // log::info!( - // "[perf] render_composite_region: {:.1}ms, copy {:.1}ms, total {:.1}ms | region {}×{}", - // engine_ms, copy_ms, total_ms, rw, rh - // ); - } else { - // Performance log triggered if the render pass returns a null buffer pointer. - // log::info!("[perf] render_composite_region: {:.1}ms, no copy (null ptr)", engine_ms); } doc.engine.clear_dirty_flags(); @@ -634,6 +739,9 @@ impl HcieIcedApp { 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 @@ -899,6 +1007,27 @@ impl HcieIcedApp { } } + 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); @@ -924,13 +1053,370 @@ impl HcieIcedApp { } // ── Layer styles ────────────────────────────── - Message::LayerStyleToggle(_index) => { - // TODO: toggle layer style enabled state + 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 enabled state: + // - Style doesn't exist → first click enables it (true) + // - Style exists → toggle current state + let new_enabled = match existing { + None => true, // First click: create ENABLED + Some(s) => { + let was_enabled = match s { + 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, + }; + !was_enabled // Toggle + } + }; + + let new_style = match disc { + "DropShadow" => LayerStyle::DropShadow { + enabled: new_enabled, + 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: new_enabled, + 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: new_enabled, + opacity: 0.75, spread: 0.0, size: 5.0, + color: [255, 255, 190, 255], blend_mode: "Screen".to_string(), + }, + "InnerGlow" => LayerStyle::InnerGlow { + enabled: new_enabled, + opacity: 0.75, spread: 0.0, size: 5.0, + color: [255, 255, 190, 255], blend_mode: "Screen".to_string(), + }, + "BevelEmboss" => LayerStyle::BevelEmboss { + enabled: new_enabled, + 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], + }, + "Satin" => LayerStyle::Satin { + enabled: new_enabled, + opacity: 0.5, angle: 120.0, distance: 11.0, size: 14.0, + color: [0, 0, 0, 255], invert: true, + }, + "ColorOverlay" => LayerStyle::ColorOverlay { + enabled: new_enabled, + opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(), + }, + "GradientOverlay" => LayerStyle::GradientOverlay { + enabled: new_enabled, + opacity: 1.0, blend_mode: "Normal".to_string(), + angle: 90.0, scale: 1.0, gradient_type: 0, + }, + "PatternOverlay" => LayerStyle::PatternOverlay { + enabled: new_enabled, + opacity: 1.0, blend_mode: "Normal".to_string(), + scale: 1.0, pattern_name: "".to_string(), + }, + "Stroke" => LayerStyle::Stroke { + enabled: new_enabled, + size: 3.0, position: "Outside".to_string(), + opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(), + }, + _ => unreachable!(), + }; + 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 => { - // TODO: open layer style add dialog + // 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::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, + _ => {} + } + 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, .. } => *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::ShowStyleColorPicker(idx) => { + self.show_style_color_picker = true; + self.style_color_picker_idx = idx; + // 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 ─────────────────────────────────── @@ -1317,7 +1803,18 @@ impl HcieIcedApp { async { rfd::AsyncFileDialog::new() .set_title("Open Image") - .add_filter("Images", &["png", "jpg", "jpeg", "webp", "psd", "kra", "hcie"]) + .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()) @@ -1330,9 +1827,22 @@ impl HcieIcedApp { 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()) @@ -1348,14 +1858,22 @@ impl HcieIcedApp { .to_lowercase(); if self.active_dialog == ActiveDialog::None { - // Open file + // Open file — route multi-layer formats to dedicated importers let engine = &mut self.documents[self.active_doc].engine; - match engine.open_image(&path_str) { + 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); + 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); @@ -1363,9 +1881,15 @@ impl HcieIcedApp { Err(e) => log::error!("Failed to open: {}", e), } } else { - // Save file + // Save file — route multi-layer formats to dedicated exporters let engine = &self.documents[self.active_doc].engine; - match engine.save_as(&path_str, &ext) { + 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), } @@ -1445,7 +1969,18 @@ impl HcieIcedApp { async { rfd::AsyncFileDialog::new() .set_title("Open Image") - .add_filter("Images", &["png", "jpg", "jpeg", "webp", "psd", "kra", "hcie"]) + .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()) @@ -1467,9 +2002,22 @@ impl HcieIcedApp { 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()) @@ -1493,28 +2041,73 @@ impl HcieIcedApp { 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; - // Dispatch the appropriate action based on menu and item indices match (menu_idx, item_idx) { - // ── File menu (0) ── (0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New (0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open - // (0, 2) = separator - (0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save - (0, 4) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save As - // (0, 5) = separator - // (0, 6) = Import SVG — TODO - // (0, 7) = Import Brushes — TODO - // (0, 8) = separator - (0, 9) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG (placeholder → Save As) - (0, 10) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG (placeholder → Save As) - // (0, 11) = separator - (0, 12) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)), // Close - // (0, 13) = Exit - (0, 13) => { - if let Some(id) = self.window_id { - return iced::window::close(id); + (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); + } + } + _ => {} + } } } @@ -1584,9 +2177,7 @@ impl HcieIcedApp { (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) = Clear Layer — TODO - // (4, 8) = separator - // (4, 9) = Layer Styles — TODO + (4, 7) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Styles // (4, 10) = separator // (4, 11) = Align Left — TODO // (4, 12) = Align Center — TODO @@ -1775,6 +2366,20 @@ impl HcieIcedApp { 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). @@ -1788,7 +2393,7 @@ impl HcieIcedApp { if has_dialog { stack = stack.push(dialog_overlay); } - if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu) { + 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(); @@ -1871,6 +2476,27 @@ impl HcieIcedApp { } }); - iced::Subscription::batch(vec![keyboard]) + // 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]) } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs index 77ce8c9..9f62a41 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs @@ -1,119 +1,347 @@ -//! Basic color picker — RGB sliders and palette grid. -//! -//! Provides a color selection interface with RGB sliders -//! and a predefined palette grid. Palette swatches are clickable. +//! Color picker — matches the egui version with: +//! - Primary/Secondary swatch buttons + swap button +//! - Editable hex input field +//! - Tab bar: W (Color Wheel), H (HSL Sliders), G (Palette Grid) +//! - Color Wheel: hue ring + saturation/lightness square +//! - HSL sliders +//! - 10x10 palette grid +//! - Recent colors use crate::app::Message; -use iced::widget::{column, container, horizontal_rule, row, slider, text}; +use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input}; use iced::{Element, Length, Padding}; -/// A predefined color palette. -const PALETTE: &[[u8; 3]] = &[ - [0, 0, 0], // Black - [255, 255, 255], // White - [255, 0, 0], // Red - [0, 255, 0], // Green - [0, 0, 255], // Blue - [255, 255, 0], // Yellow - [255, 0, 255], // Magenta - [0, 255, 255], // Cyan - [128, 0, 0], // Dark Red - [128, 128, 0], // Olive - [0, 128, 0], // Dark Green - [0, 128, 128], // Teal - [0, 0, 128], // Navy - [128, 0, 128], // Purple - [192, 192, 192], // Silver - [128, 128, 128], // Gray - [255, 128, 128], // Light Red - [255, 192, 128], // Orange - [255, 255, 128], // Light Yellow - [128, 255, 128], // Light Green - [128, 255, 255], // Light Cyan - [128, 128, 255], // Light Blue - [192, 128, 255], // Light Purple - [255, 128, 192], // Pink -]; +/// A predefined color palette (10x10 grid) — generated algorithmically to match egui. +fn palette_color(row: usize, col: usize) -> [u8; 3] { + let lightness = (95.0 - row as f32 * 80.0 / 9.0) / 100.0; + if col == 0 { + // Grayscale column + let v = (lightness * 255.0) as u8; + [v, v, v] + } else { + // Hue columns at saturation 0.85 + let hue = col as f32 * 360.0 / 10.0; + let (r, g, b) = hsl_to_rgb(hue, 0.85, lightness); + [r, g, b] + } +} -/// Build the color picker element. -pub fn view<'a>(fg_color: &'a [u8; 4], _bg_color: &'a [u8; 4]) -> Element<'a, Message> { +/// Convert RGB to HSL. +fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { + let r = r as f32 / 255.0; + let g = g as f32 / 255.0; + let b = b as f32 / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + if max == min { return (0.0, 0.0, l); } + let d = max - min; + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if max == r { + ((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0 + } else if max == g { + ((b - r) / d + 2.0) / 6.0 + } else { + ((r - g) / d + 4.0) / 6.0 + }; + (h * 360.0, s, l) +} + +/// Convert HSL to RGB. +fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { + let h = h / 360.0; + let (r, g, b) = if s == 0.0 { + (l, l, l) + } else { + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + (hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0)) + }; + ((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) +} + +fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 { + let t = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t }; + if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t } + else if t < 1.0 / 2.0 { q } + else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 } + else { p } +} + +/// Build the full color picker panel. +pub fn view<'a>( + fg_color: &'a [u8; 4], + bg_color: &'a [u8; 4], + recent_colors: &'a [[u8; 4]], + color_tab: usize, +) -> Element<'a, Message> { let r = fg_color[0]; let g = fg_color[1]; let b = fg_color[2]; + let (h, s, l) = rgb_to_hsl(r, g, b); - let r_slider = slider(0..=255, r, move |v| Message::FgColorChanged([v, g, b, 255])); - let g_slider = slider(0..=255, g, move |v| Message::FgColorChanged([r, v, b, 255])); - let b_slider = slider(0..=255, b, move |v| Message::FgColorChanged([r, g, v, 255])); - - let sliders = column![ - row![text("R").size(10), r_slider].spacing(4), - row![text("G").size(10), g_slider].spacing(4), - row![text("B").size(10), b_slider].spacing(4), - ] - .spacing(2) - .padding(Padding::from([4, 4])); - - // Color swatch preview + // ── 1. Primary/Secondary swatch + swap ── let fg = fg_color; - let preview = container(text("")) - .width(Length::Fill) - .height(32) + let bg = bg_color; + let primary_swatch = container(text("")) + .width(20) + .height(20) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - fg[0] as f32 / 255.0, - fg[1] as f32 / 255.0, - fg[2] as f32 / 255.0, - fg[3] as f32 / 255.0, + fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, 1.0, ))), - border: iced::Border::default().rounded(4).color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1), + border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2), ..Default::default() }); + let secondary_swatch = container(text("")) + .width(20) + .height(20) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, 1.0, + ))), + border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2), + ..Default::default() + }); + let swap_btn = button(text("\u{21C5}").size(11)) + .on_press(Message::SwapColors) + .padding([2, 4]); + let swatch_row = row![primary_swatch, secondary_swatch, swap_btn].spacing(4).align_y(iced::Alignment::Center); - // Palette grid — each swatch is clickable - let mut palette_grid = column![].spacing(1); - for row_colors in PALETTE.chunks(8) { - let mut row_widgets = row![].spacing(1); - for &color in row_colors { - let c = color; - let swatch = container(text("")) - .width(16) - .height(16) + // ── 2. Hex input ── + let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b); + let hex_input = text_input("#", &hex_str) + .on_input(|input| { + let clean = input.trim_start_matches('#').to_uppercase(); + if clean.len() == 6 { + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&clean[0..2], 16), + u8::from_str_radix(&clean[2..4], 16), + u8::from_str_radix(&clean[4..6], 16), + ) { + return Message::FgColorChanged([r, g, b, 255]); + } + } + Message::NoOp + }) + .width(70) + .font(iced::Font::MONOSPACE) + .size(10); + let hex_row = row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center); + + // ── 3. Tab bar ── + let tab_w = 30; + let make_tab = |label: &'static str, idx: usize, color_tab: usize| { + button(text(label).size(10)).padding([2, tab_w / 2 - 4]) + .on_press(Message::ColorTabChanged(idx)) + .style(move |_theme, _status| iced::widget::button::Style { + background: if color_tab == idx { + Some(iced::Background::Color(iced::Color::from_rgb(0.35, 0.35, 0.35))) + } else { + Some(iced::Background::Color(iced::Color::from_rgb(0.22, 0.22, 0.22))) + }, + text_color: iced::Color::from_rgb(0.8, 0.8, 0.8), + border: iced::Border::default().rounded(2), + ..Default::default() + }) + }; + let tabs = row![ + make_tab("W", 0, color_tab), + make_tab("H", 1, color_tab), + make_tab("G", 2, color_tab), + ].spacing(1); + + // ── 4. Tab content ── + let tab_content: Element<'_, Message> = match color_tab { + 0 => build_wheel_view(h, s, l), + 1 => build_slider_view(h, s, l), + _ => build_grid_view(), + }; + + // ── 5. Recent colors ── + let mut recent_items: Vec> = Vec::new(); + for &color in recent_colors.iter().take(20) { + let c = color; + let swatch = container(text("")) + .width(13) + .height(13) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0, + ))), + border: iced::Border::default().rounded(2), + ..Default::default() + }); + let clickable = iced::widget::mouse_area(swatch) + .on_press(Message::FgColorChanged([c[0], c[1], c[2], 255])) + .interaction(iced::mouse::Interaction::Pointer); + recent_items.push(clickable.into()); + } + let recent_row: Element<'_, Message> = if recent_items.is_empty() { + text("").into() + } else { + row(recent_items).spacing(2).into() + }; + + column![ + text("Color").size(11).font(iced::Font::MONOSPACE), + swatch_row, + hex_row, + tabs, + tab_content, + horizontal_rule(1), + text("Recent").size(9), + recent_row, + ] + .width(Length::Fill) + .spacing(3) + .padding(Padding::from([4, 4])) + .into() +} + +/// Build the HSL color wheel view (tab W). +/// +/// Uses a hue ring made of colored cells and an SL picker grid. +fn build_wheel_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> { + // Hue ring: 36 cells, each 10 degrees + let cell_size = 10; + let mut hue_ring = row![].spacing(0); + for i in 0..36 { + let hue = i as f32 * 10.0; + let (r, g, b) = hsl_to_rgb(hue, 1.0, 0.5); + let is_current = (h - hue).abs() < 5.0 || (h - hue).abs() > 355.0; + let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0); + + let cell = container(text("")) + .width(cell_size) + .height(cell_size) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(cell_color)), + border: if is_current { + iced::Border::default().color(iced::Color::WHITE).width(1) + } else { + iced::Border::default() + }, + ..Default::default() + }); + let h_val = hue; + let s_val = s; + let l_val = l; + let clickable = iced::widget::mouse_area(cell) + .on_press(Message::FgColorChanged({ + let (r, g, b) = hsl_to_rgb(h_val, s_val, l_val); + [r, g, b, 255] + })) + .interaction(iced::mouse::Interaction::Pointer); + hue_ring = hue_ring.push(clickable); + } + + // SL picker grid: 12 columns (saturation) x 12 rows (lightness) + let sl_size = 10; + let mut sl_grid = column![].spacing(0); + for row_idx in 0..12 { + let lightness = row_idx as f32 / 11.0; + let mut sl_row = row![].spacing(0); + for col_idx in 0..12 { + let sat = col_idx as f32 / 11.0; + let (r, g, b) = hsl_to_rgb(h, sat, lightness); + let is_current = (s - sat).abs() < 0.05 && (l - lightness).abs() < 0.05; + let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0); + + let cell = container(text("")) + .width(sl_size) + .height(sl_size) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgb( - c[0] as f32 / 255.0, - c[1] as f32 / 255.0, - c[2] as f32 / 255.0, - ))), - border: iced::Border::default().rounded(2), + background: Some(iced::Background::Color(cell_color)), + border: if is_current { + iced::Border::default().color(iced::Color::WHITE).width(1) + } else { + iced::Border::default() + }, ..Default::default() }); + let h_val = h; + let s_val = sat; + let l_val = lightness; + let clickable = iced::widget::mouse_area(cell) + .on_press(Message::FgColorChanged({ + let (r, g, b) = hsl_to_rgb(h_val, s_val, l_val); + [r, g, b, 255] + })) + .interaction(iced::mouse::Interaction::Pointer); + sl_row = sl_row.push(clickable); + } + sl_grid = sl_grid.push(sl_row); + } - // Make swatch clickable + // HSL readout + let readout = text(format!("H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", h, s * 100.0, l * 100.0)) + .size(9).font(iced::Font::MONOSPACE); + + column![ + text("Hue").size(9), + hue_ring, + text("Saturation / Lightness").size(9), + sl_grid, + readout, + ] + .spacing(3) + .into() +} + +/// Build the HSL sliders view (tab H). +fn build_slider_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> { + let h_slider = slider(0.0..=360.0, h, move |v: f32| { + let (r, g, b) = hsl_to_rgb(v, s, l); + Message::FgColorChanged([r, g, b, 255]) + }).step(1.0); + let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| { + let (r, g, b) = hsl_to_rgb(h, v / 100.0, l); + Message::FgColorChanged([r, g, b, 255]) + }).step(1.0); + let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| { + let (r, g, b) = hsl_to_rgb(h, s, v / 100.0); + Message::FgColorChanged([r, g, b, 255]) + }).step(1.0); + + column![ + row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(2), + row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(2), + row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(2), + ] + .spacing(2) + .into() +} + +/// Build the palette grid view (tab G). +fn build_grid_view<'a>() -> Element<'a, Message> { + let mut palette_grid = column![].spacing(1); + for row_idx in 0..10 { + let mut row_widgets = row![].spacing(1); + for col_idx in 0..10 { + let c = palette_color(row_idx, col_idx); + let swatch = container(text("")) + .width(14) + .height(14) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgb( + c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, + ))), + border: iced::Border::default().rounded(1), + ..Default::default() + }); let clickable = iced::widget::mouse_area(swatch) .on_press(Message::FgColorChanged([c[0], c[1], c[2], 255])) .interaction(iced::mouse::Interaction::Pointer); - row_widgets = row_widgets.push(clickable); } palette_grid = palette_grid.push(row_widgets); } - let palette = column![ + column![ text("Palette").size(10), palette_grid, ] - .spacing(4) - .padding(Padding::from([4, 4])); - - column![ - text("Color").size(12).font(iced::Font::MONOSPACE), - preview, - sliders, - horizontal_rule(1), - palette, - ] - .width(Length::Fill) - .spacing(4) - .padding(Padding::from([4, 4])) + .spacing(2) .into() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs index 3e0dae0..2846382 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs @@ -113,12 +113,12 @@ impl DockState { b: Box::new(Configuration::Pane(PaneType::History)), }; - // Right column 2: Layers (top) / Properties (bottom) + // Right column 2: Layers (top) / LayerStyles (bottom) let right_col2 = Configuration::Split { axis: iced::widget::pane_grid::Axis::Horizontal, - ratio: 0.5, + ratio: 0.6, a: Box::new(Configuration::Pane(PaneType::Layers)), - b: Box::new(Configuration::Pane(PaneType::Properties)), + b: Box::new(Configuration::Pane(PaneType::LayerStyles)), }; // Right side: right_col1 (left) / right_col2 (right) — vertical split @@ -130,19 +130,19 @@ impl DockState { }; // Center + right: Canvas (left) / right_side (right) — vertical split - // Canvas gets ~80% of the remaining space after left column. + // Canvas gets ~65% — right panels need room for layers + styles let center_right = Configuration::Split { axis: iced::widget::pane_grid::Axis::Vertical, - ratio: 0.80, + ratio: 0.65, a: Box::new(Configuration::Pane(PaneType::Canvas)), b: Box::new(right_side), }; // Root: left_column (left) / center_right (right) — vertical split - // Left column gets ~17% of total width (matches egui default). + // Left column gets ~14% of total width. let root_config = Configuration::Split { axis: iced::widget::pane_grid::Axis::Vertical, - ratio: 0.17, + ratio: 0.14, a: Box::new(left_column), b: Box::new(center_right), }; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index bdf73cd..76eb3c1 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -50,7 +50,7 @@ pub fn dock_view<'a>( // Tools is no longer in the dock — toolbox is a fixed strip on the left PaneType::Layers => { let doc = &app.documents[app.active_doc]; - crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), colors) + crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), &doc.engine, colors) } PaneType::History => { let doc = &app.documents[app.active_doc]; @@ -69,7 +69,7 @@ pub fn dock_view<'a>( crate::panels::filters::view(app.selected_filter, colors) } PaneType::ColorPicker => { - crate::color_picker::view(&app.fg_color, &app.bg_color) + crate::color_picker::view(&app.fg_color, &app.bg_color, &app.recent_colors, app.color_tab) } PaneType::Properties => { crate::panels::properties::view( @@ -89,7 +89,18 @@ pub fn dock_view<'a>( crate::panels::ai_chat_panel::view("", &[], colors) } PaneType::LayerStyles => { - crate::panels::layer_styles::view(&[], colors) + let doc = &app.documents[app.active_doc]; + let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id()); + // Leak the Vec so the Element can borrow it for the full lifetime. + // The leaked memory is reclaimed on next frame when the panel rebuilds. + let styles: &'static [hcie_engine_api::LayerStyle] = Box::leak(styles.into_boxed_slice()); + crate::panels::layer_styles::view( + styles, app.selected_style, colors, + app.show_style_color_picker, + app.style_color_picker_idx, + app.style_color_hsl, + app.layer_style_offset, + ) } PaneType::LayerDetails => { let doc = &app.documents[app.active_doc]; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_details.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_details.rs index f393b8c..e12b203 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_details.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_details.rs @@ -1,5 +1,8 @@ //! Layer details panel — detailed information about the active layer. -//! Uses ThemeColors for consistent styling. +//! +//! Shows the complete property set of the currently selected layer including +//! name, type, blend mode, opacity, visibility, lock state, effects, and +//! clipping mask status. use crate::app::Message; use crate::panels::styles; @@ -21,27 +24,34 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo row![text("Name:").size(10), text(&layer.name).size(11)].spacing(4), row![text("ID:").size(10), text(format!("{}", layer.id)).size(10)].spacing(4), row![text("Type:").size(10), text(layer_type).size(10)].spacing(4), + row![text("Size:").size(10), text(format!("{}x{}", layer.width, layer.height)).size(10)].spacing(4), horizontal_rule(1), row![text("Blend:").size(10), text(blend_mode).size(10)].spacing(4), row![text("Opacity:").size(10), text(format!("{:.0}%", layer.opacity * 100.0)).size(10)].spacing(4), row![text("Visible:").size(10), text(if layer.visible { "Yes" } else { "No" }).size(10)].spacing(4), row![text("Locked:").size(10), text(if layer.locked { "Yes" } else { "No" }).size(10)].spacing(4), + row![text("Clip Mask:").size(10), text(if layer.clipping_mask { "Yes" } else { "No" }).size(10)].spacing(4), + row![text("Parent:").size(10), text(format!("{:?}", layer.parent_id)).size(10)].spacing(4), horizontal_rule(1), row![text("Effects:").size(10), text(if layer.has_effects { "Yes" } else { "No" }).size(10)].spacing(4), + row![text("Vector:").size(10), text(if layer.has_vector_shapes { + format!("{} shapes", layer.shape_count) + } else { "None".to_string() }).size(10)].spacing(4), + row![text("Collapsed:").size(10), text(if layer.collapsed { "Yes" } else { "No" }).size(10)].spacing(4), ] - .spacing(4) + .spacing(3) .into() } None => text("No layer selected").size(11).into(), }; let panel = column![ - text("Layer Details").size(13).font(iced::Font::MONOSPACE), + text("Layer Details").size(12).font(iced::Font::MONOSPACE), horizontal_rule(1), scrollable(content).height(Length::Fill), ] - .spacing(4) - .padding(8); + .spacing(3) + .padding(6); container(panel) .width(Length::Fill) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs index fef4b3d..fdb4e61 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs @@ -1,69 +1,518 @@ -//! Layer styles panel — layer effects editor. -//! Uses ThemeColors for consistent styling. +//! Layer Style dialog — floating modal with Photoshop-like layout. +//! +//! Left sidebar: Style list with checkboxes +//! Right side: Parameters for the selected style (Blend Mode, Opacity, Color, etc.) +//! Bottom: OK/Cancel buttons +//! Includes a clickable color swatch with HSL color picker popup. use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; use hcie_engine_api::LayerStyle; -use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text}; +use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space}; use iced::{Element, Length}; -/// Build the layer styles panel. -pub fn view<'a>(styles_list: &'a [LayerStyle], colors: ThemeColors) -> Element<'a, Message> { - let mut style_list = column![].spacing(4); +/// All 10 standard Photoshop layer style types. +const STYLE_DEFS: &[(&str, &str, [f32; 3])] = &[ + ("DropShadow", "Drop Shadow", [0.86, 0.39, 0.39]), + ("InnerShadow", "Inner Shadow", [0.71, 0.47, 0.47]), + ("OuterGlow", "Outer Glow", [0.86, 0.86, 0.39]), + ("InnerGlow", "Inner Glow", [0.71, 0.71, 0.39]), + ("BevelEmboss", "Bevel & Emboss", [0.39, 0.71, 0.86]), + ("Satin", "Satin", [0.71, 0.55, 0.86]), + ("ColorOverlay", "Color Overlay", [0.5, 0.5, 0.5]), + ("GradientOverlay", "Gradient Overlay", [0.5, 0.5, 0.5]), + ("PatternOverlay", "Pattern Overlay", [0.5, 0.5, 0.5]), + ("Stroke", "Stroke", [0.5, 0.5, 0.5]), +]; - if styles_list.is_empty() { - style_list = style_list.push(text("No layer styles").size(11)); +/// Blend mode options for the dropdown. +const BLEND_MODES: &[&str] = &[ + "Normal", "Dissolve", "Darken", "Multiply", "Color Burn", "Linear Burn", + "Darker Color", "Lighten", "Screen", "Color Dodge", "Linear Dodge", + "Lighter Color", "Overlay", "Soft Light", "Hard Light", "Vivid Light", + "Linear Light", "Pin Light", "Hard Mix", "Difference", "Exclusion", + "Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity", +]; + +/// Check if a LayerStyle matches a discriminant name. +fn style_matches(discriminant: &str, style: &LayerStyle) -> bool { + matches!( + (discriminant, style), + ("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 { .. }) + ) +} + +/// Get enabled state for a style. +fn style_enabled(discriminant: &str, styles: &[LayerStyle]) -> bool { + styles.iter().find(|s| style_matches(discriminant, s)) + .map(|s| match s { + 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, + }) + .unwrap_or(false) +} + +/// Get the blend mode string for a style. +fn get_blend_mode(discriminant: &str, styles: &[LayerStyle]) -> String { + styles.iter().find(|s| style_matches(discriminant, s)) + .map(|s| match s { + 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.clone(), + _ => "Normal".to_string(), + }) + .unwrap_or_else(|| "Normal".to_string()) +} + +/// Get the color for a style (first color parameter). +fn get_style_color(discriminant: &str, styles: &[LayerStyle]) -> [u8; 4] { + styles.iter().find(|s| style_matches(discriminant, s)) + .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]) +} + +/// Convert HSL (h: 0..360, s: 0..1, l: 0..1) to RGB u8. +fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [u8; 3] { + let h = h / 360.0; + let (r, g, b) = if s == 0.0 { + (l, l, l) } else { - for (i, style) in styles_list.iter().enumerate() { - let (name, enabled) = match style { - LayerStyle::DropShadow { enabled, .. } => ("Drop Shadow", *enabled), - LayerStyle::InnerShadow { enabled, .. } => ("Inner Shadow", *enabled), - LayerStyle::OuterGlow { enabled, .. } => ("Outer Glow", *enabled), - LayerStyle::InnerGlow { enabled, .. } => ("Inner Glow", *enabled), - LayerStyle::BevelEmboss { enabled, .. } => ("Bevel & Emboss", *enabled), - LayerStyle::Satin { enabled, .. } => ("Satin", *enabled), - LayerStyle::ColorOverlay { enabled, .. } => ("Color Overlay", *enabled), - LayerStyle::GradientOverlay { enabled, .. } => ("Gradient Overlay", *enabled), - LayerStyle::PatternOverlay { enabled, .. } => ("Pattern Overlay", *enabled), - LayerStyle::Stroke { enabled, .. } => ("Stroke", *enabled), - }; + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + (hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0)) + }; + [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8] +} - let status = if enabled { "ON" } else { "OFF" }; - let toggle_btn = button(text(status).size(10)) - .on_press(Message::LayerStyleToggle(i as u32)) - .padding([2, 6]); +fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 { + let t = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t }; + if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t } + else if t < 1.0 / 2.0 { q } + else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 } + else { p } +} - let item = row![toggle_btn, text(name).size(11)].spacing(4).align_y(iced::Alignment::Center); +/// Build the floating Layer Style dialog. +pub fn view<'a>( + styles_list: &'a [LayerStyle], + selected_style: usize, + colors: ThemeColors, + show_color_picker: bool, + color_picker_idx: usize, + color_hsl: (f32, f32, f32), + drag_offset: (f32, f32), +) -> Element<'a, Message> { + // ── Left sidebar: style list ── + let mut sidebar_items = column![].spacing(1); + for (i, &(_disc, label, color_arr)) in STYLE_DEFS.iter().enumerate() { + let enabled = style_enabled(_disc, styles_list); + let is_selected = i == selected_style; - let item_style = if enabled { styles::active_item_bg(colors) } else { styles::inactive_item_bg(colors) }; + let status = if enabled { "ON" } else { "OFF" }; + let badge_color = iced::Color::from_rgb(color_arr[0], color_arr[1], color_arr[2]); - let item_container = container(item) - .width(Length::Fill) - .padding([4, 6]) - .style(move |_theme| item_style.clone()); + let toggle_btn = button(text(status).size(9)) + .on_press(Message::LayerStyleToggle(i as u32)) + .padding([1, 4]); - style_list = style_list.push(item_container); - } + let label_text = text(label).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(badge_color), + }); + + let item_row = row![toggle_btn, label_text].spacing(3).align_y(iced::Alignment::Center); + + let item_bg = if is_selected { + iced::widget::container::Style { + background: Some(iced::Background::Color( + iced::Color::from_rgba(color_arr[0], color_arr[1], color_arr[2], 0.2), + )), + border: iced::Border::default().color(iced::Color::from_rgb(color_arr[0], color_arr[1], color_arr[2])).width(1), + ..Default::default() + } + } else if enabled { + styles::active_item_bg(colors) + } else { + styles::inactive_item_bg(colors) + }; + + let item_container = container(item_row) + .width(Length::Fill) + .padding([3, 4]) + .style(move |_theme| item_bg.clone()); + + let clickable = iced::widget::mouse_area(item_container) + .on_press(Message::LayerStyleSelect(i)); + + sidebar_items = sidebar_items.push(clickable); } - let add_style_btn = button(text("+ Add Style").size(10)) - .on_press(Message::LayerStyleAdd) - .padding([4, 8]); + let sidebar = column![ + text("Styles").size(11).font(iced::Font::MONOSPACE), + horizontal_rule(1), + scrollable(sidebar_items).height(Length::Fill), + ] + .spacing(2) + .padding(4) + .width(Length::Fixed(180.0)); - let panel = column![ - text("Layer Styles").size(13).font(iced::Font::MONOSPACE), - horizontal_rule(1), - scrollable(style_list).height(Length::Fill), - horizontal_rule(1), - add_style_btn, + // ── Right side: parameters ── + let params = build_style_params(selected_style, styles_list, colors, show_color_picker, color_picker_idx, color_hsl); + + // ── Buttons ── + let ok_btn = button(text("OK").size(11)) + .on_press(Message::LayerStyleApply) + .padding([6, 20]); + let cancel_btn = button(text("Cancel").size(11)) + .on_press(Message::LayerStyleCancel) + .padding([6, 20]); + let buttons = row![ok_btn, cancel_btn].spacing(8).align_y(iced::Alignment::Center); + + // ── Dialog content ── + let content = column![ + row![ + sidebar, + column![params, horizontal_rule(1), buttons] + .width(Length::Fill) + .spacing(4) + .padding(8), + ].spacing(4), ] .spacing(4) .padding(8); - container(panel) + // ── Dialog frame with draggable title bar + X button ── + let close_btn = button(text("\u{2715}").size(12)) + .on_press(Message::LayerStyleCancel) + .padding([0, 6]); + let title_bar = row![ + text("Layer Style").size(12).font(iced::Font::MONOSPACE), + Space::with_width(Length::Fill), + close_btn, + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .padding([6, 8]); + + // Make the title bar draggable — on_press starts the drag. + // Global mouse move is handled by the subscription in app.rs. + let drag_title = iced::widget::mouse_area(title_bar) + .on_press(Message::LayerStyleDialogDragStart(0.0, 0.0)); + + let dialog = column![ + drag_title, + horizontal_rule(1), + content, + ] + .spacing(0); + + // ── Floating panel with offset from center + backdrop ── + let ox = drag_offset.0; + let oy = drag_offset.1; + let backdrop = container( + container(dialog) + .width(Length::Fixed(580.0)) + .height(Length::Fixed(420.0)) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + border: iced::Border::default().color(colors.border_high).width(2).rounded(8), + shadow: iced::Shadow { + color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6), + offset: iced::Vector::new(ox, oy), + blur_radius: 16.0, + }, + ..Default::default() + }) + .center_x(Length::Fill) + .center_y(Length::Fill) + .padding(iced::Padding { + left: ox, + right: -ox, + top: oy, + bottom: -oy, + }) + ) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))), + ..Default::default() + }); + + backdrop.into() +} + +/// Build parameter controls for a specific style. +fn build_style_params<'a>( + selected: usize, + styles_list: &[LayerStyle], + colors: ThemeColors, + show_color_picker: bool, + color_picker_idx: usize, + color_hsl: (f32, f32, f32), +) -> Element<'a, Message> { + if selected >= STYLE_DEFS.len() { + return text("Select a style from the list").size(11).into(); + } + + let (disc, label, _) = STYLE_DEFS[selected]; + let style = styles_list.iter().find(|s| style_matches(disc, s)); + let blend_mode = get_blend_mode(disc, styles_list); + let style_color = get_style_color(disc, styles_list); + + // Leak the blend mode string so pick_list can borrow it with 'static lifetime. + // This is reclaimed on next frame when the dialog rebuilds. + let blend_mode_static: &'static str = Box::leak(blend_mode.into_boxed_str()); + + let mut params = column![].spacing(4); + params = params.push(text(label).size(12).font(iced::Font::MONOSPACE)); + params = params.push(horizontal_rule(1)); + + // Blend Mode dropdown + let blend_list = pick_list(BLEND_MODES, Some(blend_mode_static), move |mode: &str| { + Message::LayerStyleUpdateBlendMode(selected, mode.to_string()) + }) + .width(Length::Fill) + .text_size(10); + + params = params.push(row![text("Blend Mode:").size(10), blend_list].spacing(4).align_y(iced::Alignment::Center)); + + // Opacity slider (common to most styles) + let opacity = match style { + Some(LayerStyle::DropShadow { opacity, .. }) + | Some(LayerStyle::InnerShadow { opacity, .. }) + | Some(LayerStyle::OuterGlow { opacity, .. }) + | Some(LayerStyle::InnerGlow { opacity, .. }) + | Some(LayerStyle::Satin { opacity, .. }) + | Some(LayerStyle::ColorOverlay { opacity, .. }) + | Some(LayerStyle::GradientOverlay { opacity, .. }) + | Some(LayerStyle::PatternOverlay { opacity, .. }) + | Some(LayerStyle::Stroke { opacity, .. }) => *opacity, + Some(LayerStyle::BevelEmboss { highlight_opacity, .. }) => *highlight_opacity, + _ => 1.0, + }; + params = params.push(slider_row("Opacity", opacity, 0.0..=1.0, move |v| Message::LayerStyleUpdateParam(selected, "opacity".to_string(), v), colors)); + + // Color swatch — clickable to open color picker + let sc = style_color; + let swatch_bg = container(text("")) + .width(32) + .height(24) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + sc[0] as f32 / 255.0, sc[1] as f32 / 255.0, sc[2] as f32 / 255.0, 1.0, + ))), + border: iced::Border::default().color(iced::Color::from_rgb(0.5, 0.5, 0.5)).width(1).rounded(2), + ..Default::default() + }); + let swatch_clickable = iced::widget::mouse_area(swatch_bg) + .on_press(Message::ShowStyleColorPicker(selected)) + .interaction(iced::mouse::Interaction::Pointer); + let color_label = row![text("Color:").size(10), swatch_clickable].spacing(4).align_y(iced::Alignment::Center); + params = params.push(color_label); + + // Style-specific sliders + match disc { + "DropShadow" | "InnerShadow" => { + let (angle, distance, spread, size) = match style { + Some(LayerStyle::DropShadow { angle, distance, spread, size, .. }) | + Some(LayerStyle::InnerShadow { angle, distance, spread, size, .. }) => + (*angle, *distance, *spread, *size), + _ => (120.0, 5.0, 0.0, 5.0), + }; + params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors)); + params = params.push(slider_row("Distance", distance, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v), colors)); + params = params.push(slider_row("Spread", spread, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v), colors)); + params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors)); + } + "OuterGlow" | "InnerGlow" => { + let (spread, size) = match style { + Some(LayerStyle::OuterGlow { spread, size, .. }) | + Some(LayerStyle::InnerGlow { spread, size, .. }) => + (*spread, *size), + _ => (0.0, 5.0), + }; + params = params.push(slider_row("Spread", spread, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v), colors)); + params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors)); + } + "BevelEmboss" => { + let (depth, size, angle, altitude, soften) = match style { + Some(LayerStyle::BevelEmboss { depth, size, angle, altitude, soften, .. }) => + (*depth, *size, *angle, *altitude, *soften), + _ => (1.0, 5.0, 120.0, 30.0, 0.0), + }; + params = params.push(slider_row("Depth", depth, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "depth".to_string(), v), colors)); + params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors)); + params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors)); + params = params.push(slider_row("Altitude", altitude, 0.0..=90.0, move |v| Message::LayerStyleUpdateParam(selected, "altitude".to_string(), v), colors)); + params = params.push(slider_row("Soften", soften, 0.0..=16.0, move |v| Message::LayerStyleUpdateParam(selected, "soften".to_string(), v), colors)); + } + "Satin" => { + let (angle, distance, size) = match style { + Some(LayerStyle::Satin { angle, distance, size, .. }) => + (*angle, *distance, *size), + _ => (120.0, 11.0, 14.0), + }; + params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors)); + params = params.push(slider_row("Distance", distance, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v), colors)); + params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors)); + } + "ColorOverlay" => { + // Only opacity — color is via the swatch above + } + "GradientOverlay" => { + let (angle, scale) = match style { + Some(LayerStyle::GradientOverlay { angle, scale, .. }) => + (*angle, *scale), + _ => (90.0, 1.0), + }; + params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors)); + params = params.push(slider_row("Scale", scale, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v), colors)); + } + "PatternOverlay" => { + let scale = match style { + Some(LayerStyle::PatternOverlay { scale, .. }) => *scale, + _ => 1.0, + }; + params = params.push(slider_row("Scale", scale, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v), colors)); + } + "Stroke" => { + let size = match style { + Some(LayerStyle::Stroke { size, .. }) => *size, + _ => 3.0, + }; + params = params.push(slider_row("Size", size, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors)); + } + _ => {} + } + + // Show color picker popup if active + if show_color_picker && color_picker_idx == selected { + params = params.push(build_color_picker_popup(color_hsl, selected, colors)); + } + + scrollable(params).height(Length::Fill).into() +} + +/// Build a compact HSL color picker popup for a layer style. +fn build_color_picker_popup<'a>( + color_hsl: (f32, f32, f32), + style_idx: usize, + colors: ThemeColors, +) -> Element<'a, Message> { + let (h, s, l) = color_hsl; + let rgb = hsl_to_rgb(h, s, l); + + // Hue slider + let h_slider = slider(0.0..=360.0, h, move |v: f32| { + let rgb = hsl_to_rgb(v, s, l); + Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255]) + }).step(1.0); + + // Saturation slider + let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| { + let rgb = hsl_to_rgb(h, v / 100.0, l); + Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255]) + }).step(1.0); + + // Lightness slider + let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| { + let rgb = hsl_to_rgb(h, s, v / 100.0); + Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255]) + }).step(1.0); + + // Color preview swatch + let preview = container(text("")) .width(Length::Fill) - .height(Length::Fill) - .style(move |_theme| styles::panel_background(colors)) + .height(20) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgb( + rgb[0] as f32 / 255.0, rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0, + ))), + border: iced::Border::default().rounded(2).color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1), + ..Default::default() + }); + + // Hex display + let hex = format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]); + + let popup = column![ + text("Color Picker").size(10).font(iced::Font::MONOSPACE), + preview, + row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(3), + row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(3), + row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(3), + row![text(hex).size(9).font(iced::Font::MONOSPACE), + Space::with_width(Length::Fill), + button(text("Close").size(9)).on_press(Message::HideStyleColorPicker).padding([2, 8])].spacing(4), + ] + .spacing(3) + .padding(6); + + container(popup) + .width(Length::Fixed(260.0)) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + border: iced::Border::default().color(colors.border_high).width(1).rounded(4), + shadow: iced::Shadow { + color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4), + offset: iced::Vector::new(0.0, 2.0), + blur_radius: 8.0, + }, + ..Default::default() + }) .into() } + +/// Build a compact slider row. +fn slider_row<'a>( + label: &'a str, + value: f32, + range: std::ops::RangeInclusive, + on_change: impl Fn(f32) -> Message + 'static, + _colors: ThemeColors, +) -> Element<'a, Message> { + let value_label = text(format!("{:.1}", value)).size(9).width(Length::Fixed(40.0)); + let label_text = text(label).size(10).width(Length::Fixed(70.0)); + + row![ + label_text, + slider(range, value, on_change).step(0.1).width(Length::Fill), + value_label, + ] + .spacing(3) + .align_y(iced::Alignment::Center) + .into() +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs index 3e47957..48da4c0 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs @@ -1,52 +1,299 @@ -//! Layers panel — layer management with visibility, opacity, blend mode. +//! Layers panel — Photoshop-like layer management with thumbnails, badges, +//! blend mode selector, opacity/fill controls, group hierarchy, and effects display. //! -//! Displays the layer stack with controls for visibility toggling, -//! opacity adjustment, blend mode selection, and layer operations. -//! Uses ThemeColors for consistent styling. +//! Layout (top to bottom): +//! 1. Active layer controls — blend mode pick_list, opacity slider, lock toggle +//! 2. Scrollable layer list — each row shows indent, collapse arrow, visibility, thumbnail, +//! name, FX icon, and type badge. Layers with effects show expandable effect sub-items. +//! 3. Bottom toolbar — add layer, add group, flatten, delete, move up/down use crate::app::Message; use crate::panels::styles; +use crate::panels::thumbnails; use crate::theme::ThemeColors; -use hcie_engine_api::{BlendMode, LayerInfo}; -use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text}; +use hcie_engine_api::{BlendMode, LayerInfo, LayerType}; +use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space}; use iced::{Element, Length}; +/// Wrapper for BlendMode to provide Display for pick_list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BlendModeItem(BlendMode); + +impl std::fmt::Display for BlendModeItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.label()) + } +} + +impl From for BlendModeItem { + fn from(m: BlendMode) -> Self { Self(m) } +} + +impl From for BlendMode { + fn from(m: BlendModeItem) -> Self { m.0 } +} + +const BLEND_MODE_ITEMS: &[BlendModeItem] = &[ + BlendModeItem(BlendMode::Normal), BlendModeItem(BlendMode::Dissolve), + BlendModeItem(BlendMode::Darken), BlendModeItem(BlendMode::Multiply), + BlendModeItem(BlendMode::ColorBurn), BlendModeItem(BlendMode::LinearBurn), + BlendModeItem(BlendMode::DarkerColor), + BlendModeItem(BlendMode::Lighten), BlendModeItem(BlendMode::Screen), + BlendModeItem(BlendMode::ColorDodge), BlendModeItem(BlendMode::LinearDodge), + BlendModeItem(BlendMode::LighterColor), + BlendModeItem(BlendMode::Overlay), BlendModeItem(BlendMode::SoftLight), + BlendModeItem(BlendMode::HardLight), BlendModeItem(BlendMode::VividLight), + BlendModeItem(BlendMode::LinearLight), BlendModeItem(BlendMode::PinLight), + BlendModeItem(BlendMode::HardMix), + BlendModeItem(BlendMode::Difference), BlendModeItem(BlendMode::Exclusion), + BlendModeItem(BlendMode::Subtract), BlendModeItem(BlendMode::Divide), + BlendModeItem(BlendMode::Hue), BlendModeItem(BlendMode::Saturation), + BlendModeItem(BlendMode::Color), BlendModeItem(BlendMode::Luminosity), + BlendModeItem(BlendMode::PassThrough), +]; + +/// Tree entry for rendering nested layer hierarchy. +struct LayerEntry<'a> { + info: &'a LayerInfo, + depth: usize, + is_group: bool, +} + +/// Build a flat rendering order from the layer list, respecting group hierarchy. +fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec> { + let mut children_of: std::collections::HashMap, Vec<&LayerInfo>> = + std::collections::HashMap::new(); + for info in layers.iter().rev() { + children_of.entry(info.parent_id).or_default().push(info); + } + + let mut result = Vec::new(); + fn walk<'a>( + parent_id: Option, + depth: usize, + children_of: &std::collections::HashMap, Vec<&'a LayerInfo>>, + result: &mut Vec>, + ) { + if let Some(children) = children_of.get(&parent_id) { + for info in children { + let is_group = info.layer_type == LayerType::Group; + result.push(LayerEntry { info, depth, is_group }); + if is_group && !info.collapsed { + walk(Some(info.id), depth + 1, children_of, result); + } + } + } + } + + walk(None, 0, &children_of, &mut result); + result +} + +/// Photoshop-like flat button style (no background, subtle border on hover). +fn flat_btn_style(colors: ThemeColors) -> iced::widget::button::Style { + iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::TRANSPARENT)), + border: iced::Border::default().color(iced::Color::TRANSPARENT), + text_color: colors.text_primary, + ..Default::default() + } +} + +/// Build the thumbnail element for a layer row. +fn layer_thumbnail<'a>( + entry: &LayerEntry<'a>, + engine: &hcie_engine_api::Engine, + colors: ThemeColors, +) -> Element<'a, Message> { + if entry.is_group { + return container(text("\u{1F4C1}").size(13)) + .width(28) + .height(20) + .center_y(Length::Fixed(20.0)) + .into(); + } + + let info = entry.info; + let thumb = engine.get_layer_pixels(info.id) + .filter(|px| !px.is_empty() && info.width > 0 && info.height > 0) + .map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 28)); + + if let Some(pixels) = thumb { + let handle = iced::widget::image::Handle::from_rgba(28, 20, pixels); + let img = iced::widget::Image::new(handle).width(28).height(20); + return container(img) + .width(30) + .height(22) + .center_y(Length::Fixed(22.0)) + .padding(1) + .style(move |_theme| iced::widget::container::Style { + border: iced::Border::default().color(colors.border_high).width(1), + ..Default::default() + }) + .into(); + } + + // Fallback: type-based icon + let (icon_char, icon_color) = match info.layer_type { + LayerType::Vector => ("V", iced::Color::from_rgb(0.4, 0.8, 0.4)), + LayerType::Text => ("T", iced::Color::from_rgb(0.4, 0.6, 1.0)), + LayerType::Mask => ("M", iced::Color::from_rgb(0.8, 0.4, 0.8)), + _ => ("\u{25A3}", colors.text_secondary), + }; + + container( + text(icon_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(icon_color), + }) + ) + .width(30) + .height(22) + .center_x(Length::Fixed(30.0)) + .center_y(Length::Fixed(22.0)) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.05))), + border: iced::Border::default().color(colors.border_high).width(1), + ..Default::default() + }) + .into() +} + /// Build the layers panel element. -pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColors) -> Element<'a, Message> { +pub fn view<'a>( + layers: &'a [LayerInfo], + active_layer_id: u64, + engine: &hcie_engine_api::Engine, + colors: ThemeColors, +) -> Element<'a, Message> { + let entries = build_tree(layers); + + // ── Active layer controls ────────────────────────────── + let active_info = layers.iter().find(|l| l.id == active_layer_id); + let active_blend = active_info.map(|l| l.blend_mode).unwrap_or(BlendMode::Normal); + let active_opacity = active_info.map(|l| l.opacity).unwrap_or(1.0); + let active_locked = active_info.map(|l| l.locked).unwrap_or(false); + + let blend_list = pick_list(BLEND_MODE_ITEMS, Some(BlendModeItem(active_blend)), move |item| { + Message::LayerSetBlendMode(active_layer_id, item.into()) + }) + .width(Length::Fill) + .text_size(10); + + let opacity_label = text(format!("{}%", (active_opacity * 100.0) as u32)) + .size(10) + .width(Length::Fixed(32.0)); + let opacity_slider = slider(0.0..=1.0, active_opacity, move |v| { + Message::LayerSetOpacity(active_layer_id, v) + }) + .step(0.01) + .width(Length::Fill); + + let opacity_row = row![text("Op").size(9), opacity_slider, opacity_label] + .spacing(3) + .align_y(iced::Alignment::Center); + + let lock_icon = if active_locked { "\u{1F512}" } else { "\u{1F513}" }; + let lock_btn = button(text(lock_icon).size(11)) + .on_press(Message::LayerToggleLock(active_layer_id)) + .padding([1, 4]) + .style(move |_theme, _status| flat_btn_style(colors)); + + let controls = column![ + row![blend_list].spacing(2), + opacity_row, + row![lock_btn].spacing(4).align_y(iced::Alignment::Center), + ] + .spacing(2); + + // ── Layer list ───────────────────────────────────────── let mut layer_list = column![].spacing(1); - // Render layers in reverse order (top layer first in UI) - for layer in layers.iter().rev() { - let is_active = layer.id == active_layer_id; + for entry in &entries { + let info = entry.info; + let is_active = info.id == active_layer_id; + let depth = entry.depth; - let name_text = text(&layer.name) - .size(11) - .width(Length::Fill); + // Indent spacer + let indent: Element<'a, Message> = Space::with_width( + Length::Fixed((depth as f32) * 14.0) + ).into(); - let name_text = if is_active { - name_text.style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(colors.accent), - }) + // Collapse arrow (groups only) + let collapse: Element<'a, Message> = if entry.is_group { + let arrow = if info.collapsed { "\u{25B6}" } else { "\u{25BC}" }; + button(text(arrow).size(8)) + .on_press(Message::LayerToggleCollapse(info.id)) + .padding([0, 1]) + .style(move |_theme, _status| flat_btn_style(colors)) + .into() } else { - name_text + Space::with_width(Length::Fixed(12.0)).into() }; // Visibility toggle - let vis_icon = if layer.visible { "👁" } else { " " }; - let vis_btn = button(text(vis_icon).size(10)) - .on_press(Message::LayerToggleVisibility(layer.id)) - .padding([2, 4]); + let vis_char = if info.visible { "\u{25CF}" } else { "\u{25CB}" }; + let vis_color = if info.visible { colors.text_primary } else { colors.text_secondary }; + let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(vis_color), + })) + .on_press(Message::LayerToggleVisibility(info.id)) + .padding([0, 2]) + .style(move |_theme, _status| flat_btn_style(colors)); - // Lock toggle - let lock_icon = if layer.locked { "🔒" } else { " " }; - let lock_btn = button(text(lock_icon).size(10)) - .on_press(Message::LayerToggleLock(layer.id)) - .padding([2, 4]); + // Thumbnail + let thumb_elem = layer_thumbnail(entry, engine, colors); + // Layer name + let c = if is_active { colors.accent } else { colors.text_primary }; + let name_text = text(&info.name) + .size(10) + .width(Length::Fill) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(c) }); + + // "fx" icon for layers with effects (Photoshop-style) + let fx_icon: Element<'a, Message> = if info.has_effects { + button( + text("fx").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.accent), + }) + ) + .on_press(Message::LayerStyleToggle(0)) // Opens styles panel + .padding([0, 2]) + .style(move |_theme, _status| flat_btn_style(colors)) + .into() + } else { + Space::with_width(Length::Fixed(1.0)).into() + }; + + // Type badge + let type_badge: Element<'a, Message> = match info.layer_type { + LayerType::Vector => text("V").size(8) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(iced::Color::from_rgb(0.4, 0.8, 0.4)), + }).into(), + LayerType::Text => text("T").size(8) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(iced::Color::from_rgb(0.4, 0.6, 1.0)), + }).into(), + LayerType::Group => text("G").size(8) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(iced::Color::from_rgb(0.85, 0.7, 0.3)), + }).into(), + LayerType::Mask => text("M").size(8) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(iced::Color::from_rgb(0.8, 0.4, 0.8)), + }).into(), + _ => text("").size(8).into(), + }; + + // Main layer row let name_row = row![ + collapse, vis_btn, - lock_btn, + thumb_elem, name_text, + fx_icon, + type_badge, ] .spacing(2) .align_y(iced::Alignment::Center); @@ -54,94 +301,116 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo let layer_item = if is_active { container(name_row) .width(Length::Fill) - .padding([4, 6]) - .style(move |_theme| styles::active_item_bg(colors)) + .padding([2, 3]) + .style(move |_theme| { + let accent_bg = iced::Color::from_rgba( + colors.accent.r, colors.accent.g, colors.accent.b, 0.18, + ); + iced::widget::container::Style { + background: Some(iced::Background::Color(accent_bg)), + border: iced::Border::default().color( + iced::Color::from_rgba(colors.accent.r, colors.accent.g, colors.accent.b, 0.4) + ).width(1), + ..Default::default() + } + }) } else { container(name_row) .width(Length::Fill) - .padding([4, 6]) + .padding([2, 3]) .style(move |_theme| styles::inactive_item_bg(colors)) }; - // Click to select layer let clickable = iced::widget::mouse_area(layer_item) - .on_press(Message::LayerSelect(layer.id)); + .on_press(Message::LayerSelect(info.id)); - layer_list = layer_list.push(clickable); + let row_with_indent = row![indent, clickable].align_y(iced::Alignment::Center); + layer_list = layer_list.push(row_with_indent); + + // ── Expandable effects sub-items (Photoshop-style) ── + if info.has_effects && is_active { + let badges = engine.get_layer_style_badges(info.id); + let effects_depth = depth + 2; + + for (code, enabled) in &badges { + if *enabled { + let effect_name = effect_full_name(code); + let effect_color = fx_color(code); + let effect_indent = Space::with_width( + Length::Fixed((effects_depth as f32) * 14.0) + ); + let effect_row = row![ + effect_indent, + text("\u{25B8}").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }), + text(effect_name).size(9).style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(effect_color), + }), + ] + .spacing(2) + .align_y(iced::Alignment::Center); + + layer_list = layer_list.push(effect_row); + } + } + } } - // Opacity slider for active layer - let active_opacity = layers.iter() - .find(|l| l.id == active_layer_id) - .map(|l| l.opacity) - .unwrap_or(1.0); - - let opacity_slider = slider(0.0..=1.0, active_opacity, move |v| { - Message::LayerSetOpacity(active_layer_id, v) - }) - .step(0.01) - .width(Length::Fill); - - let opacity_row = row![ - text("Opacity").size(10), - text(format!("{:.0}%", active_opacity * 100.0)).size(10), - ] - .spacing(4); - - // Blend mode selector - let current_blend = layers.iter() - .find(|l| l.id == active_layer_id) - .map(|l| l.blend_mode) - .unwrap_or(BlendMode::Normal); - - let blend_label = text(format!("{:?}", current_blend)).size(10); - - // Action buttons - let add_btn = button(text("+").size(12)) + // ── Bottom toolbar ───────────────────────────────────── + let add_btn = button(text("+Lay").size(10)) .on_press(Message::LayerAdd) - .padding([4, 8]); - - let del_btn = button(text("−").size(12)) - .on_press(Message::LayerDelete(active_layer_id)) - .padding([4, 8]); - - let merge_btn = button(text("Merge").size(10)) - .on_press(Message::LayerMergeDown) - .padding([4, 8]); - + .padding([3, 6]) + .style(move |_theme, _status| flat_btn_style(colors)); + let group_btn = button(text("+Grp").size(10)) + .on_press(Message::LayerAddGroup) + .padding([3, 6]) + .style(move |_theme, _status| flat_btn_style(colors)); let flatten_btn = button(text("Flatten").size(10)) .on_press(Message::LayerFlatten) - .padding([4, 8]); - - let move_up_btn = button(text("▲").size(10)) + .padding([3, 6]) + .style(move |_theme, _status| flat_btn_style(colors)); + let del_btn = button(text("\u{2716}").size(10)) + .on_press(Message::LayerDelete(active_layer_id)) + .padding([3, 6]) + .style(move |_theme, _status| flat_btn_style(colors)); + let move_up_btn = button(text("\u{25B2}").size(10)) .on_press(Message::LayerMoveUp(active_layer_id)) - .padding([4, 4]); - - let move_down_btn = button(text("▼").size(10)) + .padding([3, 4]) + .style(move |_theme, _status| flat_btn_style(colors)); + let move_down_btn = button(text("\u{25BC}").size(10)) .on_press(Message::LayerMoveDown(active_layer_id)) - .padding([4, 4]); + .padding([3, 4]) + .style(move |_theme, _status| flat_btn_style(colors)); - let buttons_row = row![ - add_btn, del_btn, merge_btn, flatten_btn, + let fx_btn = button(text("fx").size(10)) + .on_press(Message::OpenLayerStyleDialog) + .padding([3, 6]) + .style(move |_theme, _status| flat_btn_style(colors)); + let toolbar = row![ + add_btn, group_btn, flatten_btn, + iced::widget::horizontal_rule(1), + fx_btn, iced::widget::horizontal_rule(1), move_up_btn, move_down_btn, + iced::widget::horizontal_rule(1), + del_btn, ] .spacing(2) .align_y(iced::Alignment::Center); + // ── Panel layout ─────────────────────────────────────── let panel = column![ - text("Layers").size(13).font(iced::Font::MONOSPACE), + text("Layers").size(11).font(iced::Font::MONOSPACE), + horizontal_rule(1), + controls, horizontal_rule(1), scrollable(layer_list).height(Length::Fill), horizontal_rule(1), - opacity_row, - opacity_slider, - row![text("Blend:").size(10), blend_label].spacing(4), - horizontal_rule(1), - buttons_row, + toolbar, ] - .spacing(4) - .padding(8); + .spacing(2) + .padding(4); container(panel) .width(Length::Fill) @@ -149,3 +418,33 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo .style(move |_theme| styles::panel_background(colors)) .into() } + +/// Map FX badge code to full effect name (for expandable sub-items). +fn effect_full_name(code: &str) -> &'static str { + match code { + "DS" => "Drop Shadow", + "IS" => "Inner Shadow", + "OG" => "Outer Glow", + "IG" => "Inner Glow", + "BE" => "Bevel & Emboss", + "St" => "Satin", + "CO" => "Color Overlay", + "GO" => "Gradient Overlay", + "PO" => "Pattern Overlay", + "Sr" => "Stroke", + _ => "Effect", + } +} + +/// Map FX badge code to a display color (matches egui palette). +fn fx_color(code: &str) -> iced::Color { + match code { + "DS" => iced::Color::from_rgb(0.86, 0.39, 0.39), + "IS" => iced::Color::from_rgb(0.71, 0.47, 0.47), + "OG" => iced::Color::from_rgb(0.86, 0.86, 0.39), + "IG" => iced::Color::from_rgb(0.71, 0.71, 0.39), + "BE" => iced::Color::from_rgb(0.39, 0.71, 0.86), + "St" => iced::Color::from_rgb(0.71, 0.55, 0.86), + _ => iced::Color::from_rgb(0.5, 0.5, 0.5), + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index bf6427a..f117d3e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -9,63 +9,71 @@ use iced::widget::{column, container, horizontal_rule, mouse_area, row, text}; use iced::{Element, Length}; /// Menu item definition. -#[allow(dead_code)] +#[derive(Clone)] struct MenuItem { - label: &'static str, - shortcut: &'static str, + label: String, + shortcut: String, enabled: bool, } impl MenuItem { - fn new(label: &'static str) -> Self { - Self { label, shortcut: "", enabled: true } + fn new(label: &str) -> Self { + Self { label: label.to_string(), shortcut: String::new(), enabled: true } } - fn with_shortcut(label: &'static str, shortcut: &'static str) -> Self { - Self { label, shortcut, enabled: true } + fn with_shortcut(label: &str, shortcut: &str) -> Self { + Self { label: label.to_string(), shortcut: shortcut.to_string(), enabled: true } } fn separator() -> Self { - Self { label: "─", shortcut: "", enabled: false } + Self { label: "─".to_string(), shortcut: String::new(), enabled: false } } - fn disabled(label: &'static str) -> Self { - Self { label, shortcut: "", enabled: false } + fn disabled(label: &str) -> Self { + Self { label: label.to_string(), shortcut: String::new(), enabled: false } } } /// All menu definitions. +#[derive(Clone)] struct MenuDef { - #[allow(dead_code)] - label: &'static str, + label: String, items: Vec, } -fn all_menus() -> Vec { +fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { + let mut file_items = vec![ + MenuItem::with_shortcut("New", "Ctrl+N"), + MenuItem::with_shortcut("Open...", "Ctrl+O"), + ]; + + if !recent_files.is_empty() { + file_items.push(MenuItem::separator()); + file_items.push(MenuItem::disabled("Open Recent")); + for entry in recent_files.iter().take(10) { + file_items.push(MenuItem::new(&entry.name)); + } + file_items.push(MenuItem::separator()); + file_items.push(MenuItem::new("Clear Recent")); + } + + file_items.push(MenuItem::separator()); + file_items.push(MenuItem::with_shortcut("Save", "Ctrl+S")); + file_items.push(MenuItem::with_shortcut("Save As...", "Ctrl+Shift+S")); + file_items.push(MenuItem::separator()); + file_items.push(MenuItem::new("Import SVG...")); + file_items.push(MenuItem::new("Import Brushes...")); + file_items.push(MenuItem::separator()); + file_items.push(MenuItem::new("Export PNG...")); + file_items.push(MenuItem::new("Export JPEG...")); + file_items.push(MenuItem::separator()); + file_items.push(MenuItem::new("Close")); + file_items.push(MenuItem::new("Exit")); + vec![ - // File (0) + MenuDef { label: "File".to_string(), items: file_items }, MenuDef { - label: "File", - items: vec![ - MenuItem::with_shortcut("New", "Ctrl+N"), - MenuItem::with_shortcut("Open...", "Ctrl+O"), - MenuItem::separator(), - MenuItem::with_shortcut("Save", "Ctrl+S"), - MenuItem::with_shortcut("Save As...", "Ctrl+Shift+S"), - MenuItem::separator(), - MenuItem::new("Import SVG..."), - MenuItem::new("Import Brushes..."), - MenuItem::separator(), - MenuItem::new("Export PNG..."), - MenuItem::new("Export JPEG..."), - MenuItem::separator(), - MenuItem::new("Close"), - MenuItem::new("Exit"), - ], - }, - // Edit (1) - MenuDef { - label: "Edit", + label: "Edit".to_string(), items: vec![ MenuItem::with_shortcut("Undo", "Ctrl+Z"), MenuItem::with_shortcut("Redo", "Ctrl+Y"), @@ -79,60 +87,39 @@ fn all_menus() -> Vec { MenuItem::new("Invert Selection"), MenuItem::separator(), MenuItem::new("Fill..."), + MenuItem::new("Stroke..."), ], }, - // Tools (2) MenuDef { - label: "Tools", + label: "Tools".to_string(), items: vec![ + MenuItem::new("Brush"), + MenuItem::new("Eraser"), MenuItem::new("Move"), - MenuItem::new("Vector Select"), - MenuItem::separator(), - MenuItem::new("Rectangle Select"), + MenuItem::new("Marquee"), MenuItem::new("Lasso"), - MenuItem::new("Polygon Select"), MenuItem::new("Magic Wand"), MenuItem::separator(), - MenuItem::new("Crop"), + MenuItem::new("Gradient"), + MenuItem::new("Paint Bucket"), MenuItem::new("Eyedropper"), MenuItem::separator(), - MenuItem::new("Brush"), - MenuItem::new("Pen"), - MenuItem::new("Spray"), - MenuItem::new("Eraser"), - MenuItem::separator(), - MenuItem::new("Flood Fill"), - MenuItem::new("Gradient"), - MenuItem::separator(), MenuItem::new("Text"), - MenuItem::separator(), - MenuItem::new("Vector Line"), - MenuItem::new("Vector Rectangle"), - MenuItem::new("Vector Circle"), - MenuItem::separator(), - MenuItem::new("Reset Tool"), + MenuItem::new("Shape"), ], }, - // Image (3) MenuDef { - label: "Image", + label: "Image".to_string(), items: vec![ MenuItem::new("Canvas Size..."), MenuItem::new("Image Size..."), MenuItem::separator(), - MenuItem::new("Rotate 90° CW"), - MenuItem::new("Rotate 90° CCW"), - MenuItem::new("Rotate 180°"), - MenuItem::separator(), - MenuItem::new("Flip Horizontal"), - MenuItem::new("Flip Vertical"), - MenuItem::separator(), - MenuItem::new("Crop to Selection"), + MenuItem::new("Crop"), + MenuItem::new("Flatten"), ], }, - // Layer (4) MenuDef { - label: "Layer", + label: "Layer".to_string(), items: vec![ MenuItem::new("New Layer"), MenuItem::new("Duplicate Layer"), @@ -141,109 +128,135 @@ fn all_menus() -> Vec { MenuItem::new("Merge Down"), MenuItem::new("Flatten Image"), MenuItem::separator(), - MenuItem::new("Clear Layer"), - MenuItem::separator(), MenuItem::new("Layer Styles..."), - MenuItem::separator(), - MenuItem::new("Align Left"), - MenuItem::new("Align Center"), - MenuItem::new("Align Right"), ], }, - // Filter (5) MenuDef { - label: "Filter", + label: "Filter".to_string(), items: vec![ - MenuItem::disabled("─ Blur"), - MenuItem::new(" Box Blur"), - MenuItem::new(" Gaussian Blur"), - MenuItem::new(" Motion Blur"), - MenuItem::disabled("─ Sharpen"), - MenuItem::new(" Unsharp Mask"), - MenuItem::disabled("─ Pixelate"), - MenuItem::new(" Mosaic"), - MenuItem::new(" Crystallize"), - MenuItem::disabled("─ Distort"), - MenuItem::new(" Pinch"), - MenuItem::new(" Twirl"), - MenuItem::disabled("─ Stylize"), - MenuItem::new(" Oil Paint"), - MenuItem::disabled("─ Color & Light"), - MenuItem::new(" Levels"), - MenuItem::new(" Vibrance"), - MenuItem::new(" Exposure"), - MenuItem::new(" Posterize"), - MenuItem::new(" Threshold"), - MenuItem::disabled("─ Restore"), - MenuItem::new(" Dehaze"), - ], - }, - // Select (6) - MenuDef { - label: "Select", - items: vec![ - MenuItem::with_shortcut("All", "Ctrl+A"), - MenuItem::with_shortcut("Deselect", "Ctrl+D"), - MenuItem::new("Invert Selection"), + MenuItem::new("Blur"), + MenuItem::new("Sharpen"), + MenuItem::new("Noise"), + MenuItem::new("Distort"), MenuItem::separator(), - MenuItem::new("Grow..."), - MenuItem::new("Shrink..."), - MenuItem::new("Feather..."), + MenuItem::new("Apply Last Filter"), ], }, - // View (7) MenuDef { - label: "View", + label: "Select".to_string(), + items: vec![ + MenuItem::new("All"), + MenuItem::new("Deselect"), + MenuItem::new("Inverse"), + MenuItem::separator(), + MenuItem::new("Color Range..."), + ], + }, + MenuDef { + label: "View".to_string(), items: vec![ MenuItem::new("Zoom In"), MenuItem::new("Zoom Out"), - MenuItem::new("Zoom Reset"), - MenuItem::separator(), - MenuItem::new("Fit to Window"), - MenuItem::new("100%"), - MenuItem::new("200%"), - MenuItem::separator(), - MenuItem::new("Debug Mode"), + MenuItem::new("Fit on Screen"), + MenuItem::new("Actual Pixels"), ], }, - // Help (8) MenuDef { - label: "Help", + label: "Help".to_string(), items: vec![ MenuItem::new("About HCIE"), - MenuItem::new("Documentation"), - MenuItem::new("License"), ], }, ] } /// Build a dropdown menu overlay for the given menu index. -/// -/// Returns `None` if `active_menu` is `None`. The returned element is meant -/// to be placed in a `Stack` overlay on top of all other content. -pub fn dropdown_overlay<'a>(active_menu: Option) -> Option> { +pub fn dropdown_overlay(active_menu: Option, recent_files: &[crate::app::RecentFileEntry]) -> Option> { let menu_idx = active_menu?; - let menus = all_menus(); - let menu = menus.get(menu_idx)?; + let menus = all_menus(recent_files); + let menu_items = menus.get(menu_idx)?.items.clone(); - let mut items = column![].spacing(0); + let mut items: Vec> = Vec::new(); + let mut item_idx = 0; - for (item_idx, item) in menu.items.iter().enumerate() { + for item in menu_items.iter() { if item.label == "─" { - // Separator - items = items.push( + items.push( container(horizontal_rule(1)) .width(Length::Fill) .padding([2, 8]) + .into() ); + } else if item.label == "Open Recent" { + let lbl = item.label.clone(); + let disabled_item = container( + text(lbl).size(10) + ) + .width(Length::Fill) + .padding([4, 16]) + .style(|_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + ..Default::default() + }); + items.push(disabled_item.into()); + } else if item_idx >= 4 && item_idx < 4 + recent_files.len().min(10) { + let file_idx = item_idx - 4; + let lbl = item.label.clone(); + let btn = iced::widget::button( + container(text(lbl).size(11)).width(Length::Fill).padding([4, 16]) + ) + .on_press(Message::OpenRecentFile(file_idx)) + .padding(0) + .style(|_theme: &iced::Theme, status: iced::widget::button::Status| { + match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgb(0.227, 0.227, 0.227))), + text_color: iced::Color::from_rgb(0.8, 0.8, 0.8), + border: iced::Border::default(), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + text_color: iced::Color::from_rgb(0.8, 0.8, 0.8), + border: iced::Border::default(), + ..Default::default() + }, + } + }); + items.push(btn.into()); + } else if item.label == "Clear Recent" { + let lbl = item.label.clone(); + let btn = iced::widget::button( + container(text(lbl).size(11)).width(Length::Fill).padding([4, 16]) + ) + .on_press(Message::ClearRecentFiles) + .padding(0) + .style(|_theme: &iced::Theme, status: iced::widget::button::Status| { + match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgb(0.227, 0.227, 0.227))), + text_color: iced::Color::from_rgb(0.8, 0.8, 0.8), + border: iced::Border::default(), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + text_color: iced::Color::from_rgb(0.8, 0.8, 0.8), + border: iced::Border::default(), + ..Default::default() + }, + } + }); + items.push(btn.into()); } else { - let label_row: Element<'_, Message> = if item.shortcut.is_empty() { - row![text(item.label).size(11)].into() + let lbl = item.label.clone(); + let sht = item.shortcut.clone(); + let label_row: Element<'static, Message> = if sht.is_empty() { + row![text(lbl).size(11)].into() } else { row![ - text(item.label).size(11).width(Length::Fill), - text(item.shortcut).size(10), + text(lbl).size(11).width(Length::Fill), + text(sht).size(10), ] .spacing(16) .into() @@ -275,7 +288,7 @@ pub fn dropdown_overlay<'a>(active_menu: Option) -> Option(active_menu: Option) -> Option(active_menu: Option) -> Option Vec { + if width == 0 || height == 0 || pixels.len() < (width * height * 4) as usize { + return checkerboard(max_dim, (max_dim * 3) / 4); + } + + let aspect = height as f32 / width as f32; + let tw = max_dim; + let th = ((max_dim as f32) * aspect).round().max(1.0) as u32; + + let mut out = vec![0u8; (tw * th * 4) as usize]; + + for ty in 0..th { + for tx in 0..tw { + let sx = ((tx as f32 / tw as f32) * width as f32).min(width as f32 - 1.0) as u32; + let sy = ((ty as f32 / th as f32) * height as f32).min(height as f32 - 1.0) as u32; + let si = ((sy * width + sx) * 4) as usize; + let di = ((ty * tw + tx) * 4) as usize; + + if si + 3 < pixels.len() && di + 3 < out.len() { + let r = pixels[si] as f32 / 255.0; + let g = pixels[si + 1] as f32 / 255.0; + let b = pixels[si + 2] as f32 / 255.0; + let a = pixels[si + 3] as f32 / 255.0; + + // Checkerboard: light (#f0f0f0) and dark (#c0c0c0) + let cb = if (tx / 4 + ty / 4) % 2 == 0 { 240.0 } else { 192.0 }; + let bg = cb / 255.0; + + out[di] = ((r * a + bg * (1.0 - a)) * 255.0).round() as u8; + out[di + 1] = ((g * a + bg * (1.0 - a)) * 255.0).round() as u8; + out[di + 2] = ((b * a + bg * (1.0 - a)) * 255.0).round() as u8; + out[di + 3] = 255; + } + } + } + + out +} + +/// Generate a pure checkerboard placeholder thumbnail. +fn checkerboard(w: u32, h: u32) -> Vec { + let mut out = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let cb = if (x / 4 + y / 4) % 2 == 0 { 240u8 } else { 192u8 }; + let i = ((y * w + x) * 4) as usize; + out[i] = cb; + out[i + 1] = cb; + out[i + 2] = cb; + out[i + 3] = 255; + } + } + out +} diff --git a/psdpath.txt b/psdpath.txt new file mode 100644 index 0000000..55335b2 --- /dev/null +++ b/psdpath.txt @@ -0,0 +1 @@ +/mnt/extra/00_PROJECTS/hcie-rust-v3.05/_images/_test_images/example3/Example3-mini.psd diff --git a/tools/screenshot.py b/tools/screenshot.py new file mode 100644 index 0000000..7b5c35d --- /dev/null +++ b/tools/screenshot.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Take a screenshot of the HCIE iced app window using XDG portal or xdotool+import.""" +import subprocess +import sys +import time + +def try_portal_screenshot(): + """Try org.freedesktop.portal.Screenshot via dbus.""" + try: + result = subprocess.run( + ['gdbus', 'call', '--session', '--dest', 'org.freedesktop.portal.Desktop', + '--object-path', '/org/freedesktop/portal/desktop', + '--method', 'org.freedesktop.portal.Screenshot.Screenshot', + '', '{}'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + return True + except Exception: + pass + return False + +def try_import_screenshot(output): + """Try ImageMagick import.""" + try: + subprocess.run(['import', '-window', 'root', output], timeout=10) + return True + except Exception: + return False + +def try_xdg_screenshot(output): + """Try gnome-screenshot or xfce4-screenshooter.""" + for cmd in [['gnome-screenshot', '-f', output], + ['xfce4-screenshooter', '-f', '-s', output]]: + try: + subprocess.run(cmd, timeout=10) + import os + if os.path.exists(output) and os.path.getsize(output) > 0: + return True + except Exception: + continue + return False + +def try_xdotool_import(output): + """Use xdotool to find window and import.""" + try: + result = subprocess.run(['xdotool', 'search', '--name', 'HCIE'], + capture_output=True, text=True, timeout=5) + wid = result.stdout.strip().split('\n')[0] + if wid: + subprocess.run(['import', '-window', wid, output], timeout=10) + import os + if os.path.exists(output) and os.path.getsize(output) > 0: + return True + except Exception: + pass + return False + +def try_grim_screenshot(output): + """Try grim for Wayland.""" + try: + subprocess.run(['grim', output], timeout=10) + import os + if os.path.exists(output) and os.path.getsize(output) > 0: + return True + except Exception: + pass + return False + +def try_spectacle_screenshot(output): + """Try spectacle for KDE Wayland.""" + try: + subprocess.run(['spectacle', '--nonotify', '-f', output], timeout=10) + import os + if os.path.exists(output) and os.path.getsize(output) > 0: + return True + except Exception: + pass + return False + +if __name__ == '__main__': + output = sys.argv[1] if len(sys.argv) > 1 else '/tmp/hcie_screenshot.png' + + methods = [ + ("grim (Wayland)", try_grim_screenshot), + ("spectacle (KDE)", try_spectacle_screenshot), + ("import (ImageMagick)", try_import_screenshot), + ("xdotool+import", try_xdotool_import), + ("gnome-screenshot/xfce4", try_xdg_screenshot), + ] + + for name, method in methods: + print(f"Trying {name}...") + if method(output): + import os + if os.path.exists(output) and os.path.getsize(output) > 0: + print(f"SUCCESS: Screenshot saved to {output}") + sys.exit(0) + + print("FAILED: No screenshot method worked") + sys.exit(1)