This commit is contained in:
2026-07-13 06:40:14 +03:00
parent a70bedd6bb
commit 356fac571d
18 changed files with 2360 additions and 471 deletions
@@ -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
Generated
+2
View File
@@ -2794,6 +2794,7 @@ dependencies = [
"arboard", "arboard",
"bytemuck", "bytemuck",
"bytes", "bytes",
"dirs 5.0.1",
"env_logger", "env_logger",
"evdev", "evdev",
"hcie-engine-api", "hcie-engine-api",
@@ -2802,6 +2803,7 @@ dependencies = [
"image 0.25.10", "image 0.25.10",
"log", "log",
"rfd", "rfd",
"serde",
"serde_json", "serde_json",
] ]
+14
View File
@@ -1720,6 +1720,16 @@ impl Engine {
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
layer.dirty = true; layer.dirty = true;
self.document.composite_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 { if did_remove {
self.document.composite_dirty = true; 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 { if empty_styles {
self.raw_pixel_backup.remove(&id); self.raw_pixel_backup.remove(&id);
+7 -1
View File
@@ -233,6 +233,10 @@ impl Engine {
// Pass 2: Apply layer effects. Restores from backup first so that each // Pass 2: Apply layer effects. Restores from backup first so that each
// slider edit always applies on top of the original untouched pixels. // 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 { for layer in &mut self.document.layers {
if layer.effects.is_empty() && layer.styles.is_empty() { continue; } if layer.effects.is_empty() && layer.styles.is_empty() { continue; }
if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) { if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) {
@@ -258,7 +262,9 @@ impl Engine {
width: layer.width, width: layer.width,
height: layer.height, 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.effects_dirty.store(false, std::sync::atomic::Ordering::Release);
layer.dirty = true; layer.dirty = true;
} }
+3 -7
View File
@@ -127,9 +127,9 @@ impl Engine {
tip.density, tip.density,
); );
layer.dirty = true; layer.dirty = true;
// Effects are expensive (33MB clone + apply_layer_effects on 4K). // Mark effects_dirty so the effects pipeline re-runs after the
// Only mark the layer as needing an effects pass if it actually // stroke ends. This is safe because apply_effects_and_sync_tiles
// has any effects or editable styles. // no longer modifies layer.pixels — it only updates effects_cache.
if !layer.effects.is_empty() || !layer.styles.is_empty() { if !layer.effects.is_empty() || !layer.styles.is_empty() {
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
} }
@@ -166,8 +166,6 @@ impl Engine {
self.stroke_before_buf.as_deref(), self.stroke_before_buf.as_deref(),
); );
layer.dirty = true; 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() { if !layer.effects.is_empty() || !layer.styles.is_empty() {
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); 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]; 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); draw_line(layer, x0, y0, x1, y1, px_color, tip.size, mask_ref);
layer.dirty = true; 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() { if !layer.effects.is_empty() || !layer.styles.is_empty() {
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
} }
@@ -18,9 +18,11 @@ iced = { workspace = true }
env_logger = { workspace = true } env_logger = { workspace = true }
log = { workspace = true } log = { workspace = true }
image = { workspace = true } image = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
rfd = { workspace = true } rfd = { workspace = true }
arboard = { workspace = true } arboard = { workspace = true }
bytes = "1.12" bytes = "1.12"
bytemuck = { version = "1", features = ["derive"] } bytemuck = { version = "1", features = ["derive"] }
evdev = { version = "0.12", optional = true } evdev = { version = "0.12", optional = true }
dirs = "5.0"
+712 -86
View File
@@ -17,11 +17,44 @@ use crate::dock::state::{DockState, PaneType};
use crate::io::tablet::TabletState; use crate::io::tablet::TabletState;
use crate::panels; use crate::panels;
use crate::theme::ThemeState; 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::widget::{column, container, text};
use iced::{Element, Length, Task, Theme, Vector}; use iced::{Element, Length, Task, Theme, Vector};
use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
/// Returns the path to the HCIE config directory (~/.config/hcie-iced/).
/// Creates the directory if it doesn't exist.
fn config_dir() -> PathBuf {
let dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("hcie-iced");
let _ = std::fs::create_dir_all(&dir);
dir
}
/// Returns the path to the recent files JSON config.
fn recent_files_path() -> PathBuf {
config_dir().join("recent_files.json")
}
/// Load recent files from disk.
fn load_recent_files() -> Vec<RecentFileEntry> {
let path = recent_files_path();
match std::fs::read_to_string(&path) {
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// Save recent files to disk.
fn save_recent_files(files: &[RecentFileEntry]) {
let path = recent_files_path();
if let Ok(json) = serde_json::to_string_pretty(files) {
let _ = std::fs::write(&path, json);
}
}
/// Active dialog type. /// Active dialog type.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)] #[allow(dead_code)]
@@ -32,6 +65,7 @@ pub enum ActiveDialog {
HueSaturation, HueSaturation,
CloseConfirm, CloseConfirm,
SelectionOp(&'static str), SelectionOp(&'static str),
LayerStyleDialog,
} }
/// Top-level application state. /// Top-level application state.
@@ -83,6 +117,33 @@ pub struct HcieIcedApp {
pub theme_state: ThemeState, pub theme_state: ThemeState,
/// Currently open menu index (None = no menu open). /// Currently open menu index (None = no menu open).
pub active_menu: Option<usize>, pub active_menu: Option<usize>,
/// Recently opened files (most recent first, max 20).
pub recent_files: Vec<RecentFileEntry>,
/// Recent colors used (most recent first, max 40).
pub recent_colors: Vec<[u8; 4]>,
/// Selected style index in Layer Styles panel.
pub selected_style: usize,
/// Whether the color picker popup is open for a layer style.
pub show_style_color_picker: bool,
/// Index of the style whose color picker is open.
pub style_color_picker_idx: usize,
/// HSL color state for the style color picker (hue 0..360, sat 0..1, light 0..1).
pub style_color_hsl: (f32, f32, f32),
/// 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. /// Per-document state wrapping an engine instance.
@@ -183,6 +244,7 @@ pub enum Message {
FgColorChanged([u8; 4]), FgColorChanged([u8; 4]),
BgColorChanged([u8; 4]), BgColorChanged([u8; 4]),
SwapColors, SwapColors,
ColorTabChanged(usize),
// ── Canvas interaction ────────────────────────────── // ── Canvas interaction ──────────────────────────────
CanvasPointerPressed { x: f32, y: f32 }, CanvasPointerPressed { x: f32, y: f32 },
@@ -210,6 +272,10 @@ pub enum Message {
LayerFlatten, LayerFlatten,
LayerMoveUp(u64), LayerMoveUp(u64),
LayerMoveDown(u64), LayerMoveDown(u64),
LayerSetBlendMode(u64, BlendMode),
LayerSetFillOpacity(u64, f32),
LayerAddGroup,
LayerToggleCollapse(u64),
// ── History ───────────────────────────────────────── // ── History ─────────────────────────────────────────
HistoryJumpTo(i32), HistoryJumpTo(i32),
@@ -223,6 +289,18 @@ pub enum Message {
// ── Layer styles ──────────────────────────────────── // ── Layer styles ────────────────────────────────────
LayerStyleToggle(u32), 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, LayerStyleAdd,
// ── Brushes ───────────────────────────────────────── // ── Brushes ─────────────────────────────────────────
@@ -310,6 +388,8 @@ pub enum Message {
MenuOpen(usize), MenuOpen(usize),
MenuClose, MenuClose,
MenuAction(usize, usize), // (menu_index, item_index) MenuAction(usize, usize), // (menu_index, item_index)
OpenRecentFile(usize), // index into recent_files
ClearRecentFiles,
// ── Window ─────────────────────────────────────────── // ── Window ───────────────────────────────────────────
WindowDrag, WindowDrag,
@@ -446,20 +526,59 @@ impl HcieIcedApp {
initialized: false, initialized: false,
theme_state: ThemeState::new(), theme_state: ThemeState::new(),
active_menu: None, 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 { if let Some(path) = load_path {
let path_str = path.to_string_lossy().to_string(); 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(()) => { Ok(()) => {
app.documents[0].engine.pre_tile_all_layers();
app.documents[0].name = path.file_name() app.documents[0].name = path.file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".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<u8>.
// This is safe and avoids the raw-pointer segfault in
// render_composite_region.
let composite = app.documents[0].engine.get_composite_pixels();
let size = composite.len();
log::info!("[startup] composite pixels: {} bytes ({}x{})", size,
app.documents[0].engine.canvas_width(), app.documents[0].engine.canvas_height());
app.documents[0].composite_raw = composite;
app.documents[0].composite_pixels = std::sync::Arc::new(app.documents[0].composite_raw.clone());
app.documents[0].full_upload.replace(true); app.documents[0].full_upload.replace(true);
app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1); 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) => { Err(e) => {
log::error!("Failed to open file on startup: {}", 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. /// Refresh the composite buffer using incremental dirty-region compositing.
/// ///
/// Uses `render_composite_region()` which only re-composites dirty tiles. /// Uses `render_composite_region()` which only re-composites dirty tiles.
@@ -524,60 +666,23 @@ impl HcieIcedApp {
let doc = &mut self.documents[self.active_doc]; let doc = &mut self.documents[self.active_doc];
if doc.engine.is_composite_dirty() { if doc.engine.is_composite_dirty() {
let t0 = std::time::Instant::now(); // Use get_composite_pixels() instead of render_composite_region()
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region(); // to avoid segfaults when canvas dimensions change (e.g. after PSD import).
let t1 = std::time::Instant::now(); // get_composite_pixels() returns a safe Vec<u8> via composite_layers().
let _engine_ms = t1.duration_since(t0).as_secs_f64() * 1000.0; let composite = doc.engine.get_composite_pixels();
let size = composite.len();
if !buf_ptr.is_null() && buf_size > 0 { if size > 0 {
let region = region_result.unwrap_or([0, 0, doc.engine.canvas_width(), doc.engine.canvas_height()]); doc.composite_raw = composite;
let _rw = (region[2] - region[0]) as usize; // Update the shared Arc — if no other reference exists, mutate in-place
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.
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) { if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
unsafe { pixels.resize(doc.composite_raw.len(), 0);
std::ptr::copy_nonoverlapping(buf_ptr, pixels.as_mut_ptr(), buf_size); pixels.copy_from_slice(&doc.composite_raw);
}
} else { } else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone()); doc.composite_pixels = Arc::new(doc.composite_raw.clone());
} }
doc.full_upload.replace(true);
if region_result.is_none() { doc.dirty_region.replace(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.render_generation = doc.render_generation.wrapping_add(1); 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(); doc.engine.clear_dirty_flags();
@@ -634,6 +739,9 @@ impl HcieIcedApp {
let color = self.fg_color; let color = self.fg_color;
self.active_document_mut().engine.set_color(color); self.active_document_mut().engine.set_color(color);
} }
Message::ColorTabChanged(tab) => {
self.color_tab = tab;
}
Message::CanvasPointerPressed { x, y } => { Message::CanvasPointerPressed { x, y } => {
// Coordinates are already in canvas-space from the canvas widget // 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 ─────────────────────────────────── // ── History ───────────────────────────────────
Message::HistoryJumpTo(idx) => { Message::HistoryJumpTo(idx) => {
self.documents[self.active_doc].engine.jump_to_history(idx); self.documents[self.active_doc].engine.jump_to_history(idx);
@@ -924,13 +1053,370 @@ impl HcieIcedApp {
} }
// ── Layer styles ────────────────────────────── // ── Layer styles ──────────────────────────────
Message::LayerStyleToggle(_index) => { Message::LayerStyleToggle(index) => {
// TODO: toggle layer style enabled state // 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(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); 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 => { 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 ─────────────────────────────────── // ── Brushes ───────────────────────────────────
@@ -1317,7 +1803,18 @@ impl HcieIcedApp {
async { async {
rfd::AsyncFileDialog::new() rfd::AsyncFileDialog::new()
.set_title("Open Image") .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() .pick_file()
.await .await
.map(|handle| handle.path().to_path_buf()) .map(|handle| handle.path().to_path_buf())
@@ -1330,9 +1827,22 @@ impl HcieIcedApp {
async { async {
rfd::AsyncFileDialog::new() rfd::AsyncFileDialog::new()
.set_title("Save As") .set_title("Save As")
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.add_filter("PNG", &["png"]) .add_filter("PNG", &["png"])
.add_filter("JPEG", &["jpg", "jpeg"]) .add_filter("JPEG", &["jpg", "jpeg"])
.add_filter("WebP", &["webp"]) .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() .save_file()
.await .await
.map(|handle| handle.path().to_path_buf()) .map(|handle| handle.path().to_path_buf())
@@ -1348,14 +1858,22 @@ impl HcieIcedApp {
.to_lowercase(); .to_lowercase();
if self.active_dialog == ActiveDialog::None { 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; 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(()) => { Ok(()) => {
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path.file_name() self.documents[self.active_doc].name = path.file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".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.documents[self.active_doc].full_upload.replace(true);
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
@@ -1363,9 +1881,15 @@ impl HcieIcedApp {
Err(e) => log::error!("Failed to open: {}", e), Err(e) => log::error!("Failed to open: {}", e),
} }
} else { } else {
// Save file // Save file — route multi-layer formats to dedicated exporters
let engine = &self.documents[self.active_doc].engine; 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), Ok(()) => log::info!("Saved to {}", path_str),
Err(e) => log::error!("Failed to save: {}", e), Err(e) => log::error!("Failed to save: {}", e),
} }
@@ -1445,7 +1969,18 @@ impl HcieIcedApp {
async { async {
rfd::AsyncFileDialog::new() rfd::AsyncFileDialog::new()
.set_title("Open Image") .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() .pick_file()
.await .await
.map(|handle| handle.path().to_path_buf()) .map(|handle| handle.path().to_path_buf())
@@ -1467,9 +2002,22 @@ impl HcieIcedApp {
async { async {
rfd::AsyncFileDialog::new() rfd::AsyncFileDialog::new()
.set_title("Save As") .set_title("Save As")
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.add_filter("PNG", &["png"]) .add_filter("PNG", &["png"])
.add_filter("JPEG", &["jpg", "jpeg"]) .add_filter("JPEG", &["jpg", "jpeg"])
.add_filter("WebP", &["webp"]) .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() .save_file()
.await .await
.map(|handle| handle.path().to_path_buf()) .map(|handle| handle.path().to_path_buf())
@@ -1493,28 +2041,73 @@ impl HcieIcedApp {
self.active_dialog = ActiveDialog::None; self.active_dialog = ActiveDialog::None;
} }
} }
Message::OpenRecentFile(idx) => {
self.active_menu = None;
// Clone the path to avoid borrow conflict
let path_str = self.recent_files.get(idx).map(|e| e.path.clone());
if let Some(path_str) = path_str {
let path = std::path::PathBuf::from(&path_str);
if path.exists() {
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => self.documents[self.active_doc].engine.import_psd(&path_str),
"kra" => self.documents[self.active_doc].engine.import_kra(&path_str),
"hcie" => self.documents[self.active_doc].engine.load_native(&path_str),
_ => self.documents[self.active_doc].engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path.clone());
self.add_recent_file(&path);
let composite = self.documents[self.active_doc].engine.get_composite_pixels();
self.documents[self.active_doc].composite_raw = composite;
self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(self.documents[self.active_doc].composite_raw.clone());
self.documents[self.active_doc].full_upload.replace(true);
self.documents[self.active_doc].render_generation = self.documents[self.active_doc].render_generation.wrapping_add(1);
self.documents[self.active_doc].cached_layers = self.documents[self.active_doc].engine.layer_infos();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => log::error!("Failed to open recent file: {}", e),
}
} else {
self.recent_files.retain(|e| e.path != path_str);
}
}
}
Message::ClearRecentFiles => {
self.active_menu = None;
self.recent_files.clear();
save_recent_files(&self.recent_files);
}
Message::MenuAction(menu_idx, item_idx) => { Message::MenuAction(menu_idx, item_idx) => {
self.active_menu = None; self.active_menu = None;
// Dispatch the appropriate action based on menu and item indices
match (menu_idx, item_idx) { match (menu_idx, item_idx) {
// ── File menu (0) ──
(0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New (0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open (0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open
// (0, 2) = separator (0, _) if item_idx >= 2 => {
(0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save let recent_count = self.recent_files.len().min(10);
(0, 4) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save As let static_base = if recent_count > 0 { 4 + recent_count + 3 } else { 2 };
// (0, 5) = separator if item_idx >= static_base {
// (0, 6) = Import SVG — TODO match item_idx - static_base {
// (0, 7) = Import Brushes — TODO 0 => return Task::perform(async {}, |_| Message::SaveFileAs),
// (0, 8) = separator 1 => return Task::perform(async {}, |_| Message::SaveFileAs),
(0, 9) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG (placeholder → Save As) 6 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG
(0, 10) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG (placeholder → Save As) 7 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG
// (0, 11) = separator 9 => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)),
(0, 12) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)), // Close 10 => {
// (0, 13) = Exit if let Some(id) = self.window_id {
(0, 13) => { return iced::window::close(id);
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, 4) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down
(4, 5) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image (4, 5) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image
// (4, 6) = separator // (4, 6) = separator
// (4, 7) = Clear Layer — TODO (4, 7) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Styles
// (4, 8) = separator
// (4, 9) = Layer Styles — TODO
// (4, 10) = separator // (4, 10) = separator
// (4, 11) = Align Left — TODO // (4, 11) = Align Left — TODO
// (4, 12) = Align Center — TODO // (4, 12) = Align Center — TODO
@@ -1775,6 +2366,20 @@ impl HcieIcedApp {
1.0, 1.0,
100.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). // Stack main content with overlays (dialog + menu dropdown).
@@ -1788,7 +2393,7 @@ impl HcieIcedApp {
if has_dialog { if has_dialog {
stack = stack.push(dialog_overlay); 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); stack = stack.push(menu_overlay);
} }
let _elapsed = view_start.elapsed(); 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])
} }
} }
@@ -1,119 +1,347 @@
//! Basic color picker — RGB sliders and palette grid. //! Color picker — matches the egui version with:
//! //! - Primary/Secondary swatch buttons + swap button
//! Provides a color selection interface with RGB sliders //! - Editable hex input field
//! and a predefined palette grid. Palette swatches are clickable. //! - 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 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}; use iced::{Element, Length, Padding};
/// A predefined color palette. /// A predefined color palette (10x10 grid) — generated algorithmically to match egui.
const PALETTE: &[[u8; 3]] = &[ fn palette_color(row: usize, col: usize) -> [u8; 3] {
[0, 0, 0], // Black let lightness = (95.0 - row as f32 * 80.0 / 9.0) / 100.0;
[255, 255, 255], // White if col == 0 {
[255, 0, 0], // Red // Grayscale column
[0, 255, 0], // Green let v = (lightness * 255.0) as u8;
[0, 0, 255], // Blue [v, v, v]
[255, 255, 0], // Yellow } else {
[255, 0, 255], // Magenta // Hue columns at saturation 0.85
[0, 255, 255], // Cyan let hue = col as f32 * 360.0 / 10.0;
[128, 0, 0], // Dark Red let (r, g, b) = hsl_to_rgb(hue, 0.85, lightness);
[128, 128, 0], // Olive [r, g, b]
[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
];
/// Build the color picker element. /// Convert RGB to HSL.
pub fn view<'a>(fg_color: &'a [u8; 4], _bg_color: &'a [u8; 4]) -> Element<'a, Message> { 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 r = fg_color[0];
let g = fg_color[1]; let g = fg_color[1];
let b = fg_color[2]; 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])); // ── 1. Primary/Secondary swatch + swap ──
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
let fg = fg_color; let fg = fg_color;
let preview = container(text("")) let bg = bg_color;
.width(Length::Fill) let primary_swatch = container(text(""))
.height(32) .width(20)
.height(20)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( background: Some(iced::Background::Color(iced::Color::from_rgba(
fg[0] as f32 / 255.0, fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, 1.0,
fg[1] as f32 / 255.0,
fg[2] as f32 / 255.0,
fg[3] as f32 / 255.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() ..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 // ── 2. Hex input ──
let mut palette_grid = column![].spacing(1); let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b);
for row_colors in PALETTE.chunks(8) { let hex_input = text_input("#", &hex_str)
let mut row_widgets = row![].spacing(1); .on_input(|input| {
for &color in row_colors { let clean = input.trim_start_matches('#').to_uppercase();
let c = color; if clean.len() == 6 {
let swatch = container(text("")) if let (Ok(r), Ok(g), Ok(b)) = (
.width(16) u8::from_str_radix(&clean[0..2], 16),
.height(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<Element<'_, Message>> = 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 { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb( background: Some(iced::Background::Color(cell_color)),
c[0] as f32 / 255.0, border: if is_current {
c[1] as f32 / 255.0, iced::Border::default().color(iced::Color::WHITE).width(1)
c[2] as f32 / 255.0, } else {
))), iced::Border::default()
border: iced::Border::default().rounded(2), },
..Default::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) let clickable = iced::widget::mouse_area(swatch)
.on_press(Message::FgColorChanged([c[0], c[1], c[2], 255])) .on_press(Message::FgColorChanged([c[0], c[1], c[2], 255]))
.interaction(iced::mouse::Interaction::Pointer); .interaction(iced::mouse::Interaction::Pointer);
row_widgets = row_widgets.push(clickable); row_widgets = row_widgets.push(clickable);
} }
palette_grid = palette_grid.push(row_widgets); palette_grid = palette_grid.push(row_widgets);
} }
let palette = column![ column![
text("Palette").size(10), text("Palette").size(10),
palette_grid, palette_grid,
] ]
.spacing(4) .spacing(2)
.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]))
.into() .into()
} }
@@ -113,12 +113,12 @@ impl DockState {
b: Box::new(Configuration::Pane(PaneType::History)), 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 { let right_col2 = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Horizontal, axis: iced::widget::pane_grid::Axis::Horizontal,
ratio: 0.5, ratio: 0.6,
a: Box::new(Configuration::Pane(PaneType::Layers)), 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 // 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 // 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 { let center_right = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Vertical, axis: iced::widget::pane_grid::Axis::Vertical,
ratio: 0.80, ratio: 0.65,
a: Box::new(Configuration::Pane(PaneType::Canvas)), a: Box::new(Configuration::Pane(PaneType::Canvas)),
b: Box::new(right_side), b: Box::new(right_side),
}; };
// Root: left_column (left) / center_right (right) — vertical split // 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 { let root_config = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Vertical, axis: iced::widget::pane_grid::Axis::Vertical,
ratio: 0.17, ratio: 0.14,
a: Box::new(left_column), a: Box::new(left_column),
b: Box::new(center_right), b: Box::new(center_right),
}; };
@@ -50,7 +50,7 @@ pub fn dock_view<'a>(
// Tools is no longer in the dock — toolbox is a fixed strip on the left // Tools is no longer in the dock — toolbox is a fixed strip on the left
PaneType::Layers => { PaneType::Layers => {
let doc = &app.documents[app.active_doc]; 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 => { PaneType::History => {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
@@ -69,7 +69,7 @@ pub fn dock_view<'a>(
crate::panels::filters::view(app.selected_filter, colors) crate::panels::filters::view(app.selected_filter, colors)
} }
PaneType::ColorPicker => { 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 => { PaneType::Properties => {
crate::panels::properties::view( crate::panels::properties::view(
@@ -89,7 +89,18 @@ pub fn dock_view<'a>(
crate::panels::ai_chat_panel::view("", &[], colors) crate::panels::ai_chat_panel::view("", &[], colors)
} }
PaneType::LayerStyles => { 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 => { PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
@@ -1,5 +1,8 @@
//! Layer details panel — detailed information about the active layer. //! 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::app::Message;
use crate::panels::styles; 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("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("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("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), horizontal_rule(1),
row![text("Blend:").size(10), text(blend_mode).size(10)].spacing(4), 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("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("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("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), horizontal_rule(1),
row![text("Effects:").size(10), text(if layer.has_effects { "Yes" } else { "No" }).size(10)].spacing(4), 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() .into()
} }
None => text("No layer selected").size(11).into(), None => text("No layer selected").size(11).into(),
}; };
let panel = column![ let panel = column![
text("Layer Details").size(13).font(iced::Font::MONOSPACE), text("Layer Details").size(12).font(iced::Font::MONOSPACE),
horizontal_rule(1), horizontal_rule(1),
scrollable(content).height(Length::Fill), scrollable(content).height(Length::Fill),
] ]
.spacing(4) .spacing(3)
.padding(8); .padding(6);
container(panel) container(panel)
.width(Length::Fill) .width(Length::Fill)
@@ -1,69 +1,518 @@
//! Layer styles panel — layer effects editor. //! Layer Style dialog — floating modal with Photoshop-like layout.
//! Uses ThemeColors for consistent styling. //!
//! 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::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::LayerStyle; 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}; use iced::{Element, Length};
/// Build the layer styles panel. /// All 10 standard Photoshop layer style types.
pub fn view<'a>(styles_list: &'a [LayerStyle], colors: ThemeColors) -> Element<'a, Message> { const STYLE_DEFS: &[(&str, &str, [f32; 3])] = &[
let mut style_list = column![].spacing(4); ("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() { /// Blend mode options for the dropdown.
style_list = style_list.push(text("No layer styles").size(11)); 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 { } else {
for (i, style) in styles_list.iter().enumerate() { let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let (name, enabled) = match style { let p = 2.0 * l - q;
LayerStyle::DropShadow { enabled, .. } => ("Drop Shadow", *enabled), (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))
LayerStyle::InnerShadow { enabled, .. } => ("Inner Shadow", *enabled), };
LayerStyle::OuterGlow { enabled, .. } => ("Outer Glow", *enabled), [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8]
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 status = if enabled { "ON" } else { "OFF" }; fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 {
let toggle_btn = button(text(status).size(10)) let t = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t };
.on_press(Message::LayerStyleToggle(i as u32)) if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t }
.padding([2, 6]); 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) let toggle_btn = button(text(status).size(9))
.width(Length::Fill) .on_press(Message::LayerStyleToggle(i as u32))
.padding([4, 6]) .padding([1, 4]);
.style(move |_theme| item_style.clone());
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)) let sidebar = column![
.on_press(Message::LayerStyleAdd) text("Styles").size(11).font(iced::Font::MONOSPACE),
.padding([4, 8]); horizontal_rule(1),
scrollable(sidebar_items).height(Length::Fill),
]
.spacing(2)
.padding(4)
.width(Length::Fixed(180.0));
let panel = column![ // ── Right side: parameters ──
text("Layer Styles").size(13).font(iced::Font::MONOSPACE), let params = build_style_params(selected_style, styles_list, colors, show_color_picker, color_picker_idx, color_hsl);
horizontal_rule(1),
scrollable(style_list).height(Length::Fill), // ── Buttons ──
horizontal_rule(1), let ok_btn = button(text("OK").size(11))
add_style_btn, .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) .spacing(4)
.padding(8); .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) .width(Length::Fill)
.height(Length::Fill) .height(20)
.style(move |_theme| styles::panel_background(colors)) .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() .into()
} }
/// Build a compact slider row.
fn slider_row<'a>(
label: &'a str,
value: f32,
range: std::ops::RangeInclusive<f32>,
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()
}
@@ -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, //! Layout (top to bottom):
//! opacity adjustment, blend mode selection, and layer operations. //! 1. Active layer controls — blend mode pick_list, opacity slider, lock toggle
//! Uses ThemeColors for consistent styling. //! 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::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::panels::thumbnails;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::{BlendMode, LayerInfo}; use hcie_engine_api::{BlendMode, LayerInfo, LayerType};
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text}; use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space};
use iced::{Element, Length}; 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<BlendMode> for BlendModeItem {
fn from(m: BlendMode) -> Self { Self(m) }
}
impl From<BlendModeItem> 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<LayerEntry<'a>> {
let mut children_of: std::collections::HashMap<Option<u64>, 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<u64>,
depth: usize,
children_of: &std::collections::HashMap<Option<u64>, Vec<&'a LayerInfo>>,
result: &mut Vec<LayerEntry<'a>>,
) {
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. /// 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); let mut layer_list = column![].spacing(1);
// Render layers in reverse order (top layer first in UI) for entry in &entries {
for layer in layers.iter().rev() { let info = entry.info;
let is_active = layer.id == active_layer_id; let is_active = info.id == active_layer_id;
let depth = entry.depth;
let name_text = text(&layer.name) // Indent spacer
.size(11) let indent: Element<'a, Message> = Space::with_width(
.width(Length::Fill); Length::Fixed((depth as f32) * 14.0)
).into();
let name_text = if is_active { // Collapse arrow (groups only)
name_text.style(move |_theme: &iced::Theme| iced::widget::text::Style { let collapse: Element<'a, Message> = if entry.is_group {
color: Some(colors.accent), 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 { } else {
name_text Space::with_width(Length::Fixed(12.0)).into()
}; };
// Visibility toggle // Visibility toggle
let vis_icon = if layer.visible { "👁" } else { " " }; let vis_char = if info.visible { "\u{25CF}" } else { "\u{25CB}" };
let vis_btn = button(text(vis_icon).size(10)) let vis_color = if info.visible { colors.text_primary } else { colors.text_secondary };
.on_press(Message::LayerToggleVisibility(layer.id)) let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
.padding([2, 4]); color: Some(vis_color),
}))
.on_press(Message::LayerToggleVisibility(info.id))
.padding([0, 2])
.style(move |_theme, _status| flat_btn_style(colors));
// Lock toggle // Thumbnail
let lock_icon = if layer.locked { "🔒" } else { " " }; let thumb_elem = layer_thumbnail(entry, engine, colors);
let lock_btn = button(text(lock_icon).size(10))
.on_press(Message::LayerToggleLock(layer.id))
.padding([2, 4]);
// 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![ let name_row = row![
collapse,
vis_btn, vis_btn,
lock_btn, thumb_elem,
name_text, name_text,
fx_icon,
type_badge,
] ]
.spacing(2) .spacing(2)
.align_y(iced::Alignment::Center); .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 { let layer_item = if is_active {
container(name_row) container(name_row)
.width(Length::Fill) .width(Length::Fill)
.padding([4, 6]) .padding([2, 3])
.style(move |_theme| styles::active_item_bg(colors)) .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 { } else {
container(name_row) container(name_row)
.width(Length::Fill) .width(Length::Fill)
.padding([4, 6]) .padding([2, 3])
.style(move |_theme| styles::inactive_item_bg(colors)) .style(move |_theme| styles::inactive_item_bg(colors))
}; };
// Click to select layer
let clickable = iced::widget::mouse_area(layer_item) 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 // ── Bottom toolbar ─────────────────────────────────────
let active_opacity = layers.iter() let add_btn = button(text("+Lay").size(10))
.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))
.on_press(Message::LayerAdd) .on_press(Message::LayerAdd)
.padding([4, 8]); .padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors));
let del_btn = button(text("").size(12)) let group_btn = button(text("+Grp").size(10))
.on_press(Message::LayerDelete(active_layer_id)) .on_press(Message::LayerAddGroup)
.padding([4, 8]); .padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors));
let merge_btn = button(text("Merge").size(10))
.on_press(Message::LayerMergeDown)
.padding([4, 8]);
let flatten_btn = button(text("Flatten").size(10)) let flatten_btn = button(text("Flatten").size(10))
.on_press(Message::LayerFlatten) .on_press(Message::LayerFlatten)
.padding([4, 8]); .padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors));
let move_up_btn = button(text("").size(10)) 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)) .on_press(Message::LayerMoveUp(active_layer_id))
.padding([4, 4]); .padding([3, 4])
.style(move |_theme, _status| flat_btn_style(colors));
let move_down_btn = button(text("").size(10)) let move_down_btn = button(text("\u{25BC}").size(10))
.on_press(Message::LayerMoveDown(active_layer_id)) .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![ let fx_btn = button(text("fx").size(10))
add_btn, del_btn, merge_btn, flatten_btn, .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), iced::widget::horizontal_rule(1),
move_up_btn, move_down_btn, move_up_btn, move_down_btn,
iced::widget::horizontal_rule(1),
del_btn,
] ]
.spacing(2) .spacing(2)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
// ── Panel layout ───────────────────────────────────────
let panel = column![ 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), horizontal_rule(1),
scrollable(layer_list).height(Length::Fill), scrollable(layer_list).height(Length::Fill),
horizontal_rule(1), horizontal_rule(1),
opacity_row, toolbar,
opacity_slider,
row![text("Blend:").size(10), blend_label].spacing(4),
horizontal_rule(1),
buttons_row,
] ]
.spacing(4) .spacing(2)
.padding(8); .padding(4);
container(panel) container(panel)
.width(Length::Fill) .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)) .style(move |_theme| styles::panel_background(colors))
.into() .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),
}
}
@@ -9,63 +9,71 @@ use iced::widget::{column, container, horizontal_rule, mouse_area, row, text};
use iced::{Element, Length}; use iced::{Element, Length};
/// Menu item definition. /// Menu item definition.
#[allow(dead_code)] #[derive(Clone)]
struct MenuItem { struct MenuItem {
label: &'static str, label: String,
shortcut: &'static str, shortcut: String,
enabled: bool, enabled: bool,
} }
impl MenuItem { impl MenuItem {
fn new(label: &'static str) -> Self { fn new(label: &str) -> Self {
Self { label, shortcut: "", enabled: true } Self { label: label.to_string(), shortcut: String::new(), enabled: true }
} }
fn with_shortcut(label: &'static str, shortcut: &'static str) -> Self { fn with_shortcut(label: &str, shortcut: &str) -> Self {
Self { label, shortcut, enabled: true } Self { label: label.to_string(), shortcut: shortcut.to_string(), enabled: true }
} }
fn separator() -> Self { fn separator() -> Self {
Self { label: "", shortcut: "", enabled: false } Self { label: "".to_string(), shortcut: String::new(), enabled: false }
} }
fn disabled(label: &'static str) -> Self { fn disabled(label: &str) -> Self {
Self { label, shortcut: "", enabled: false } Self { label: label.to_string(), shortcut: String::new(), enabled: false }
} }
} }
/// All menu definitions. /// All menu definitions.
#[derive(Clone)]
struct MenuDef { struct MenuDef {
#[allow(dead_code)] label: String,
label: &'static str,
items: Vec<MenuItem>, items: Vec<MenuItem>,
} }
fn all_menus() -> Vec<MenuDef> { fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
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![ vec![
// File (0) MenuDef { label: "File".to_string(), items: file_items },
MenuDef { MenuDef {
label: "File", label: "Edit".to_string(),
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",
items: vec![ items: vec![
MenuItem::with_shortcut("Undo", "Ctrl+Z"), MenuItem::with_shortcut("Undo", "Ctrl+Z"),
MenuItem::with_shortcut("Redo", "Ctrl+Y"), MenuItem::with_shortcut("Redo", "Ctrl+Y"),
@@ -79,60 +87,39 @@ fn all_menus() -> Vec<MenuDef> {
MenuItem::new("Invert Selection"), MenuItem::new("Invert Selection"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Fill..."), MenuItem::new("Fill..."),
MenuItem::new("Stroke..."),
], ],
}, },
// Tools (2)
MenuDef { MenuDef {
label: "Tools", label: "Tools".to_string(),
items: vec![ items: vec![
MenuItem::new("Brush"),
MenuItem::new("Eraser"),
MenuItem::new("Move"), MenuItem::new("Move"),
MenuItem::new("Vector Select"), MenuItem::new("Marquee"),
MenuItem::separator(),
MenuItem::new("Rectangle Select"),
MenuItem::new("Lasso"), MenuItem::new("Lasso"),
MenuItem::new("Polygon Select"),
MenuItem::new("Magic Wand"), MenuItem::new("Magic Wand"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Crop"), MenuItem::new("Gradient"),
MenuItem::new("Paint Bucket"),
MenuItem::new("Eyedropper"), MenuItem::new("Eyedropper"),
MenuItem::separator(), 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::new("Text"),
MenuItem::separator(), MenuItem::new("Shape"),
MenuItem::new("Vector Line"),
MenuItem::new("Vector Rectangle"),
MenuItem::new("Vector Circle"),
MenuItem::separator(),
MenuItem::new("Reset Tool"),
], ],
}, },
// Image (3)
MenuDef { MenuDef {
label: "Image", label: "Image".to_string(),
items: vec![ items: vec![
MenuItem::new("Canvas Size..."), MenuItem::new("Canvas Size..."),
MenuItem::new("Image Size..."), MenuItem::new("Image Size..."),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Rotate 90° CW"), MenuItem::new("Crop"),
MenuItem::new("Rotate 90° CCW"), MenuItem::new("Flatten"),
MenuItem::new("Rotate 180°"),
MenuItem::separator(),
MenuItem::new("Flip Horizontal"),
MenuItem::new("Flip Vertical"),
MenuItem::separator(),
MenuItem::new("Crop to Selection"),
], ],
}, },
// Layer (4)
MenuDef { MenuDef {
label: "Layer", label: "Layer".to_string(),
items: vec![ items: vec![
MenuItem::new("New Layer"), MenuItem::new("New Layer"),
MenuItem::new("Duplicate Layer"), MenuItem::new("Duplicate Layer"),
@@ -141,109 +128,135 @@ fn all_menus() -> Vec<MenuDef> {
MenuItem::new("Merge Down"), MenuItem::new("Merge Down"),
MenuItem::new("Flatten Image"), MenuItem::new("Flatten Image"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Clear Layer"),
MenuItem::separator(),
MenuItem::new("Layer Styles..."), MenuItem::new("Layer Styles..."),
MenuItem::separator(),
MenuItem::new("Align Left"),
MenuItem::new("Align Center"),
MenuItem::new("Align Right"),
], ],
}, },
// Filter (5)
MenuDef { MenuDef {
label: "Filter", label: "Filter".to_string(),
items: vec![ items: vec![
MenuItem::disabled("─ Blur"), MenuItem::new("Blur"),
MenuItem::new(" Box Blur"), MenuItem::new("Sharpen"),
MenuItem::new(" Gaussian Blur"), MenuItem::new("Noise"),
MenuItem::new(" Motion Blur"), MenuItem::new("Distort"),
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::separator(), MenuItem::separator(),
MenuItem::new("Grow..."), MenuItem::new("Apply Last Filter"),
MenuItem::new("Shrink..."),
MenuItem::new("Feather..."),
], ],
}, },
// View (7)
MenuDef { 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![ items: vec![
MenuItem::new("Zoom In"), MenuItem::new("Zoom In"),
MenuItem::new("Zoom Out"), MenuItem::new("Zoom Out"),
MenuItem::new("Zoom Reset"), MenuItem::new("Fit on Screen"),
MenuItem::separator(), MenuItem::new("Actual Pixels"),
MenuItem::new("Fit to Window"),
MenuItem::new("100%"),
MenuItem::new("200%"),
MenuItem::separator(),
MenuItem::new("Debug Mode"),
], ],
}, },
// Help (8)
MenuDef { MenuDef {
label: "Help", label: "Help".to_string(),
items: vec![ items: vec![
MenuItem::new("About HCIE"), MenuItem::new("About HCIE"),
MenuItem::new("Documentation"),
MenuItem::new("License"),
], ],
}, },
] ]
} }
/// Build a dropdown menu overlay for the given menu index. /// Build a dropdown menu overlay for the given menu index.
/// pub fn dropdown_overlay(active_menu: Option<usize>, recent_files: &[crate::app::RecentFileEntry]) -> Option<Element<'static, Message>> {
/// 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<usize>) -> Option<Element<'a, Message>> {
let menu_idx = active_menu?; let menu_idx = active_menu?;
let menus = all_menus(); let menus = all_menus(recent_files);
let menu = menus.get(menu_idx)?; let menu_items = menus.get(menu_idx)?.items.clone();
let mut items = column![].spacing(0); let mut items: Vec<Element<'static, Message>> = 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 == "" { if item.label == "" {
// Separator items.push(
items = items.push(
container(horizontal_rule(1)) container(horizontal_rule(1))
.width(Length::Fill) .width(Length::Fill)
.padding([2, 8]) .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 { } else {
let label_row: Element<'_, Message> = if item.shortcut.is_empty() { let lbl = item.label.clone();
row![text(item.label).size(11)].into() let sht = item.shortcut.clone();
let label_row: Element<'static, Message> = if sht.is_empty() {
row![text(lbl).size(11)].into()
} else { } else {
row![ row![
text(item.label).size(11).width(Length::Fill), text(lbl).size(11).width(Length::Fill),
text(item.shortcut).size(10), text(sht).size(10),
] ]
.spacing(16) .spacing(16)
.into() .into()
@@ -275,7 +288,7 @@ pub fn dropdown_overlay<'a>(active_menu: Option<usize>) -> Option<Element<'a, Me
}, },
} }
}); });
items = items.push(btn); items.push(btn.into());
} else { } else {
let disabled_item = container(label_row) let disabled_item = container(label_row)
.width(Length::Fill) .width(Length::Fill)
@@ -284,12 +297,15 @@ pub fn dropdown_overlay<'a>(active_menu: Option<usize>) -> Option<Element<'a, Me
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
..Default::default() ..Default::default()
}); });
items = items.push(disabled_item); items.push(disabled_item.into());
} }
} }
item_idx += 1;
} }
let dropdown = container(items) let col = column(items).spacing(0);
let dropdown = container(col)
.width(220) .width(220)
.style(|_theme| iced::widget::container::Style { .style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.176, 0.176, 0.176))), background: Some(iced::Background::Color(iced::Color::from_rgb(0.176, 0.176, 0.176))),
@@ -304,8 +320,6 @@ pub fn dropdown_overlay<'a>(active_menu: Option<usize>) -> Option<Element<'a, Me
..Default::default() ..Default::default()
}); });
// Wrap in a mouse_area that closes the menu when clicking outside
// The padding pushes the dropdown down below the title bar
let overlay = mouse_area( let overlay = mouse_area(
container(dropdown) container(dropdown)
.padding(iced::Padding { .padding(iced::Padding {
@@ -15,4 +15,5 @@ pub mod script_panel;
pub mod status_bar; pub mod status_bar;
pub mod styles; pub mod styles;
pub mod text_editor; pub mod text_editor;
pub mod thumbnails;
pub mod title_bar; pub mod title_bar;
@@ -0,0 +1,65 @@
//! Layer thumbnail generation — nearest-neighbor downscale with checkerboard.
//!
//! Generates small RGBA thumbnail images from layer pixel data for display
//! in the layers panel. Uses nearest-neighbor interpolation to preserve
//! sharp edges at small sizes.
/// Generate a thumbnail from RGBA pixel data.
///
/// Down-scales the layer pixels to fit within `max_dim` while preserving
/// aspect ratio. Returns RGBA bytes suitable for `iced::widget::Image::from_pixels()`.
/// Transparent areas are composited over a light checkerboard pattern.
pub fn generate_thumbnail(pixels: &[u8], width: u32, height: u32, max_dim: u32) -> Vec<u8> {
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<u8> {
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
}
+1
View File
@@ -0,0 +1 @@
/mnt/extra/00_PROJECTS/hcie-rust-v3.05/_images/_test_images/example3/Example3-mini.psd
+101
View File
@@ -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)