mimo
This commit is contained in:
@@ -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
@@ -2794,6 +2794,7 @@ dependencies = [
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
"dirs 5.0.1",
|
||||
"env_logger",
|
||||
"evdev",
|
||||
"hcie-engine-api",
|
||||
@@ -2802,6 +2803,7 @@ dependencies = [
|
||||
"image 0.25.10",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
@@ -1720,6 +1720,16 @@ impl Engine {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
layer.dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
// Mark the full canvas as dirty so the next render recomposites.
|
||||
// Without this, dirty_bounds stays None and partial_composite forces
|
||||
// a full-canvas recomposite anyway — but other property ops set this
|
||||
// explicitly so we should too for consistency and correctness.
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
// Invalidate the below-layer composite cache since this layer's
|
||||
// pixels changed via the effects pipeline.
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1836,6 +1846,10 @@ impl Engine {
|
||||
}
|
||||
if did_remove {
|
||||
self.document.composite_dirty = true;
|
||||
let w = self.document.canvas_width;
|
||||
let h = self.document.canvas_height;
|
||||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
if empty_styles {
|
||||
self.raw_pixel_backup.remove(&id);
|
||||
|
||||
@@ -233,6 +233,10 @@ impl Engine {
|
||||
|
||||
// Pass 2: Apply layer effects. Restores from backup first so that each
|
||||
// slider edit always applies on top of the original untouched pixels.
|
||||
// CRITICAL: Do NOT modify layer.pixels with effects output. layer.pixels
|
||||
// must always contain the raw drawing data so that new strokes are drawn
|
||||
// on top of clean pixels (not on effects-applied pixels). The composite
|
||||
// uses effects_cache for layers with effects, not layer.pixels.
|
||||
for layer in &mut self.document.layers {
|
||||
if layer.effects.is_empty() && layer.styles.is_empty() { continue; }
|
||||
if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) {
|
||||
@@ -258,7 +262,9 @@ impl Engine {
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
});
|
||||
layer.pixels = processed;
|
||||
// DON'T: layer.pixels = processed;
|
||||
// layer.pixels stays as raw drawing data. The composite pipeline
|
||||
// in tiled.rs reads from effects_cache when it exists.
|
||||
layer.effects_dirty.store(false, std::sync::atomic::Ordering::Release);
|
||||
layer.dirty = true;
|
||||
}
|
||||
|
||||
@@ -127,9 +127,9 @@ impl Engine {
|
||||
tip.density,
|
||||
);
|
||||
layer.dirty = true;
|
||||
// Effects are expensive (33MB clone + apply_layer_effects on 4K).
|
||||
// Only mark the layer as needing an effects pass if it actually
|
||||
// has any effects or editable styles.
|
||||
// Mark effects_dirty so the effects pipeline re-runs after the
|
||||
// stroke ends. This is safe because apply_effects_and_sync_tiles
|
||||
// no longer modifies layer.pixels — it only updates effects_cache.
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
@@ -166,8 +166,6 @@ impl Engine {
|
||||
self.stroke_before_buf.as_deref(),
|
||||
);
|
||||
layer.dirty = true;
|
||||
// Avoid triggering the expensive effects pass for layers that
|
||||
// have no effects/styles.
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
@@ -200,8 +198,6 @@ impl Engine {
|
||||
let px_color = [color[0], color[1], color[2], (color[3] as f32 * tip.opacity * pressure).round() as u8];
|
||||
draw_line(layer, x0, y0, x1, y1, px_color, tip.size, mask_ref);
|
||||
layer.dirty = true;
|
||||
// Only request an effects re-render if the layer actually has
|
||||
// effects or editable styles.
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ iced = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
bytes = "1.12"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
evdev = { version = "0.12", optional = true }
|
||||
dirs = "5.0"
|
||||
|
||||
@@ -17,11 +17,44 @@ use crate::dock::state::{DockState, PaneType};
|
||||
use crate::io::tablet::TabletState;
|
||||
use crate::panels;
|
||||
use crate::theme::ThemeState;
|
||||
use hcie_engine_api::{BrushTip, BrushStyle, Engine, FilterType, Tool, ZOOM_MAX, ZOOM_MIN};
|
||||
use hcie_engine_api::{BlendMode, BrushTip, BrushStyle, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN};
|
||||
use iced::widget::{column, container, text};
|
||||
use iced::{Element, Length, Task, Theme, Vector};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Returns the path to the HCIE config directory (~/.config/hcie-iced/).
|
||||
/// Creates the directory if it doesn't exist.
|
||||
fn config_dir() -> PathBuf {
|
||||
let dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("hcie-iced");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Returns the path to the recent files JSON config.
|
||||
fn recent_files_path() -> PathBuf {
|
||||
config_dir().join("recent_files.json")
|
||||
}
|
||||
|
||||
/// Load recent files from disk.
|
||||
fn load_recent_files() -> Vec<RecentFileEntry> {
|
||||
let path = recent_files_path();
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save recent files to disk.
|
||||
fn save_recent_files(files: &[RecentFileEntry]) {
|
||||
let path = recent_files_path();
|
||||
if let Ok(json) = serde_json::to_string_pretty(files) {
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Active dialog type.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(dead_code)]
|
||||
@@ -32,6 +65,7 @@ pub enum ActiveDialog {
|
||||
HueSaturation,
|
||||
CloseConfirm,
|
||||
SelectionOp(&'static str),
|
||||
LayerStyleDialog,
|
||||
}
|
||||
|
||||
/// Top-level application state.
|
||||
@@ -83,6 +117,33 @@ pub struct HcieIcedApp {
|
||||
pub theme_state: ThemeState,
|
||||
/// Currently open menu index (None = no menu open).
|
||||
pub active_menu: Option<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.
|
||||
@@ -183,6 +244,7 @@ pub enum Message {
|
||||
FgColorChanged([u8; 4]),
|
||||
BgColorChanged([u8; 4]),
|
||||
SwapColors,
|
||||
ColorTabChanged(usize),
|
||||
|
||||
// ── Canvas interaction ──────────────────────────────
|
||||
CanvasPointerPressed { x: f32, y: f32 },
|
||||
@@ -210,6 +272,10 @@ pub enum Message {
|
||||
LayerFlatten,
|
||||
LayerMoveUp(u64),
|
||||
LayerMoveDown(u64),
|
||||
LayerSetBlendMode(u64, BlendMode),
|
||||
LayerSetFillOpacity(u64, f32),
|
||||
LayerAddGroup,
|
||||
LayerToggleCollapse(u64),
|
||||
|
||||
// ── History ─────────────────────────────────────────
|
||||
HistoryJumpTo(i32),
|
||||
@@ -223,6 +289,18 @@ pub enum Message {
|
||||
|
||||
// ── Layer styles ────────────────────────────────────
|
||||
LayerStyleToggle(u32),
|
||||
LayerStyleSelect(usize),
|
||||
LayerStyleApply,
|
||||
LayerStyleCancel,
|
||||
LayerStyleUpdateParam(usize, String, f32),
|
||||
LayerStyleUpdateBlendMode(usize, String),
|
||||
LayerStyleUpdateColor(usize, [u8; 4]),
|
||||
ShowStyleColorPicker(usize),
|
||||
HideStyleColorPicker,
|
||||
OpenLayerStyleDialog,
|
||||
LayerStyleDialogDragStart(f32, f32),
|
||||
LayerStyleDialogDragMove(f32, f32),
|
||||
LayerStyleDialogDragEnd,
|
||||
LayerStyleAdd,
|
||||
|
||||
// ── Brushes ─────────────────────────────────────────
|
||||
@@ -310,6 +388,8 @@ pub enum Message {
|
||||
MenuOpen(usize),
|
||||
MenuClose,
|
||||
MenuAction(usize, usize), // (menu_index, item_index)
|
||||
OpenRecentFile(usize), // index into recent_files
|
||||
ClearRecentFiles,
|
||||
|
||||
// ── Window ───────────────────────────────────────────
|
||||
WindowDrag,
|
||||
@@ -446,20 +526,59 @@ impl HcieIcedApp {
|
||||
initialized: false,
|
||||
theme_state: ThemeState::new(),
|
||||
active_menu: None,
|
||||
recent_files: load_recent_files(),
|
||||
recent_colors: Vec::new(),
|
||||
selected_style: 0,
|
||||
show_style_color_picker: false,
|
||||
style_color_picker_idx: 0,
|
||||
style_color_hsl: (0.0, 0.0, 0.0),
|
||||
color_tab: 0,
|
||||
layer_style_offset: (0.0, 0.0),
|
||||
layer_style_dragging: false,
|
||||
layer_style_drag_start: None,
|
||||
};
|
||||
|
||||
// If a file path was provided, open it
|
||||
// If a file path was provided, open it (route multi-layer formats to dedicated importers)
|
||||
if let Some(path) = load_path {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
match app.documents[0].engine.open_image(&path_str) {
|
||||
let ext = path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let result = match ext.as_str() {
|
||||
"psd" => app.documents[0].engine.import_psd(&path_str),
|
||||
"kra" => app.documents[0].engine.import_kra(&path_str),
|
||||
"hcie" => app.documents[0].engine.load_native(&path_str),
|
||||
_ => app.documents[0].engine.open_image(&path_str),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
app.documents[0].engine.pre_tile_all_layers();
|
||||
app.documents[0].name = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
app.documents[0].source_path = Some(path);
|
||||
app.documents[0].source_path = Some(path.clone());
|
||||
app.add_recent_file(&path);
|
||||
// Get the composite via the safe Vec-returning path.
|
||||
// This calls get_composite_pixels() which internally uses
|
||||
// composite_layers() — a pure-Rust function that returns Vec<u8>.
|
||||
// This is safe and avoids the raw-pointer segfault in
|
||||
// render_composite_region.
|
||||
let composite = app.documents[0].engine.get_composite_pixels();
|
||||
let size = composite.len();
|
||||
log::info!("[startup] composite pixels: {} bytes ({}x{})", size,
|
||||
app.documents[0].engine.canvas_width(), app.documents[0].engine.canvas_height());
|
||||
app.documents[0].composite_raw = composite;
|
||||
app.documents[0].composite_pixels = std::sync::Arc::new(app.documents[0].composite_raw.clone());
|
||||
app.documents[0].full_upload.replace(true);
|
||||
app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1);
|
||||
app.refresh_composite_if_needed();
|
||||
// Refresh cached layer list and history for panels
|
||||
app.documents[0].cached_layers = app.documents[0].engine.layer_infos();
|
||||
let history_len = app.documents[0].engine.history_len();
|
||||
app.documents[0].cached_history = (0..history_len)
|
||||
.filter_map(|i| app.documents[0].engine.history_description(i).map(|d| (i, d)))
|
||||
.collect();
|
||||
app.documents[0].history_current = app.documents[0].engine.history_current();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to open file on startup: {}", e);
|
||||
@@ -515,6 +634,29 @@ impl HcieIcedApp {
|
||||
|
||||
|
||||
|
||||
/// Add a file to the recent files list (most recent first, max 20) and persist to disk.
|
||||
fn add_recent_file(&mut self, path: &std::path::Path) {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let name = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path_str.clone());
|
||||
// Remove duplicates
|
||||
self.recent_files.retain(|e| e.path != path_str);
|
||||
// Insert at front
|
||||
self.recent_files.insert(0, RecentFileEntry { path: path_str, name });
|
||||
// Cap at 20
|
||||
self.recent_files.truncate(20);
|
||||
// Persist to disk
|
||||
save_recent_files(&self.recent_files);
|
||||
}
|
||||
|
||||
/// Add a color to the recent colors list (most recent first, max 40).
|
||||
fn add_recent_color(&mut self, color: [u8; 4]) {
|
||||
self.recent_colors.retain(|c| *c != color);
|
||||
self.recent_colors.insert(0, color);
|
||||
self.recent_colors.truncate(40);
|
||||
}
|
||||
|
||||
/// Refresh the composite buffer using incremental dirty-region compositing.
|
||||
///
|
||||
/// Uses `render_composite_region()` which only re-composites dirty tiles.
|
||||
@@ -524,60 +666,23 @@ impl HcieIcedApp {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
|
||||
if doc.engine.is_composite_dirty() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
||||
let t1 = std::time::Instant::now();
|
||||
let _engine_ms = t1.duration_since(t0).as_secs_f64() * 1000.0;
|
||||
|
||||
if !buf_ptr.is_null() && buf_size > 0 {
|
||||
let region = region_result.unwrap_or([0, 0, doc.engine.canvas_width(), doc.engine.canvas_height()]);
|
||||
let _rw = (region[2] - region[0]) as usize;
|
||||
let _rh = (region[3] - region[1]) as usize;
|
||||
|
||||
// Ensure local buffer matches engine dimensions.
|
||||
if doc.composite_raw.len() != buf_size {
|
||||
doc.composite_raw = vec![0u8; buf_size];
|
||||
}
|
||||
|
||||
// Copy the full composite scratch buffer into our raw buffer.
|
||||
// Since buf_ptr points to the persistent composite_scratch which contains
|
||||
// the full valid canvas, a full CPU copy (<0.1ms) is extremely cheap and
|
||||
// ensures we have no gaps/missing pixels due to dirty bounds mismatches.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_raw.as_mut_ptr(), buf_size);
|
||||
}
|
||||
|
||||
// Mutate the shared composite pixels in-place if no other references exist,
|
||||
// completely avoiding the 33MB cloning overhead on the UI thread.
|
||||
// Use get_composite_pixels() instead of render_composite_region()
|
||||
// to avoid segfaults when canvas dimensions change (e.g. after PSD import).
|
||||
// get_composite_pixels() returns a safe Vec<u8> via composite_layers().
|
||||
let composite = doc.engine.get_composite_pixels();
|
||||
let size = composite.len();
|
||||
if size > 0 {
|
||||
doc.composite_raw = composite;
|
||||
// Update the shared Arc — if no other reference exists, mutate in-place
|
||||
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(buf_ptr, pixels.as_mut_ptr(), buf_size);
|
||||
}
|
||||
pixels.resize(doc.composite_raw.len(), 0);
|
||||
pixels.copy_from_slice(&doc.composite_raw);
|
||||
} else {
|
||||
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
|
||||
}
|
||||
|
||||
if region_result.is_none() {
|
||||
doc.full_upload.replace(true);
|
||||
doc.dirty_region.replace(None);
|
||||
} else if !*doc.full_upload.borrow() {
|
||||
let new_region = union_regions(*doc.dirty_region.borrow(), region);
|
||||
doc.dirty_region.replace(Some(new_region));
|
||||
}
|
||||
doc.render_generation = doc.render_generation.wrapping_add(1);
|
||||
|
||||
let t2 = std::time::Instant::now();
|
||||
let _total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0;
|
||||
let _copy_ms = t2.duration_since(t1).as_secs_f64() * 1000.0;
|
||||
// Performance log for engine rendering duration + CPU memory copy times.
|
||||
// useful to check if dirty-region composite calculations start slowing down.
|
||||
// log::info!(
|
||||
// "[perf] render_composite_region: {:.1}ms, copy {:.1}ms, total {:.1}ms | region {}×{}",
|
||||
// engine_ms, copy_ms, total_ms, rw, rh
|
||||
// );
|
||||
} else {
|
||||
// Performance log triggered if the render pass returns a null buffer pointer.
|
||||
// log::info!("[perf] render_composite_region: {:.1}ms, no copy (null ptr)", engine_ms);
|
||||
}
|
||||
|
||||
doc.engine.clear_dirty_flags();
|
||||
@@ -634,6 +739,9 @@ impl HcieIcedApp {
|
||||
let color = self.fg_color;
|
||||
self.active_document_mut().engine.set_color(color);
|
||||
}
|
||||
Message::ColorTabChanged(tab) => {
|
||||
self.color_tab = tab;
|
||||
}
|
||||
|
||||
Message::CanvasPointerPressed { x, y } => {
|
||||
// Coordinates are already in canvas-space from the canvas widget
|
||||
@@ -899,6 +1007,27 @@ impl HcieIcedApp {
|
||||
}
|
||||
}
|
||||
|
||||
Message::LayerSetBlendMode(id, mode) => {
|
||||
self.documents[self.active_doc].engine.set_layer_blend_mode(id, mode);
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
|
||||
Message::LayerSetFillOpacity(_id, _opacity) => {
|
||||
// Fill opacity is not yet exposed via engine API; placeholder for future use.
|
||||
}
|
||||
|
||||
Message::LayerAddGroup => {
|
||||
let count = self.documents[self.active_doc].engine.get_layer_count();
|
||||
self.documents[self.active_doc].engine.add_group(&format!("Group {}", count + 1));
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
|
||||
Message::LayerToggleCollapse(id) => {
|
||||
self.documents[self.active_doc].engine.toggle_group_collapsed(id);
|
||||
}
|
||||
|
||||
// ── History ───────────────────────────────────
|
||||
Message::HistoryJumpTo(idx) => {
|
||||
self.documents[self.active_doc].engine.jump_to_history(idx);
|
||||
@@ -924,13 +1053,370 @@ impl HcieIcedApp {
|
||||
}
|
||||
|
||||
// ── Layer styles ──────────────────────────────
|
||||
Message::LayerStyleToggle(_index) => {
|
||||
// TODO: toggle layer style enabled state
|
||||
Message::LayerStyleToggle(index) => {
|
||||
// Toggle layer style enabled state.
|
||||
// BUG FIX: When style doesn't exist, first click creates it ENABLED (not disabled).
|
||||
// Previous logic used map_or(false, ...) which created disabled styles on first click.
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
if layer_id != 0 {
|
||||
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
|
||||
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
|
||||
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
|
||||
if (index as usize) < disc_names.len() {
|
||||
let disc = disc_names[index as usize];
|
||||
// Find existing style
|
||||
let existing = styles.iter().find(|s| {
|
||||
matches!((disc, s),
|
||||
("DropShadow", LayerStyle::DropShadow { .. })
|
||||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||||
| ("Satin", LayerStyle::Satin { .. })
|
||||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||||
| ("Stroke", LayerStyle::Stroke { .. })
|
||||
)
|
||||
});
|
||||
|
||||
// Determine new enabled state:
|
||||
// - Style doesn't exist → first click enables it (true)
|
||||
// - Style exists → toggle current state
|
||||
let new_enabled = match existing {
|
||||
None => true, // First click: create ENABLED
|
||||
Some(s) => {
|
||||
let was_enabled = match s {
|
||||
LayerStyle::DropShadow { enabled, .. }
|
||||
| LayerStyle::InnerShadow { enabled, .. }
|
||||
| LayerStyle::OuterGlow { enabled, .. }
|
||||
| LayerStyle::InnerGlow { enabled, .. }
|
||||
| LayerStyle::BevelEmboss { enabled, .. }
|
||||
| LayerStyle::Satin { enabled, .. }
|
||||
| LayerStyle::ColorOverlay { enabled, .. }
|
||||
| LayerStyle::GradientOverlay { enabled, .. }
|
||||
| LayerStyle::PatternOverlay { enabled, .. }
|
||||
| LayerStyle::Stroke { enabled, .. } => *enabled,
|
||||
};
|
||||
!was_enabled // Toggle
|
||||
}
|
||||
};
|
||||
|
||||
let new_style = match disc {
|
||||
"DropShadow" => LayerStyle::DropShadow {
|
||||
enabled: new_enabled,
|
||||
opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0,
|
||||
color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(),
|
||||
},
|
||||
"InnerShadow" => LayerStyle::InnerShadow {
|
||||
enabled: new_enabled,
|
||||
opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0,
|
||||
color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(),
|
||||
},
|
||||
"OuterGlow" => LayerStyle::OuterGlow {
|
||||
enabled: new_enabled,
|
||||
opacity: 0.75, spread: 0.0, size: 5.0,
|
||||
color: [255, 255, 190, 255], blend_mode: "Screen".to_string(),
|
||||
},
|
||||
"InnerGlow" => LayerStyle::InnerGlow {
|
||||
enabled: new_enabled,
|
||||
opacity: 0.75, spread: 0.0, size: 5.0,
|
||||
color: [255, 255, 190, 255], blend_mode: "Screen".to_string(),
|
||||
},
|
||||
"BevelEmboss" => LayerStyle::BevelEmboss {
|
||||
enabled: new_enabled,
|
||||
depth: 1.0, size: 5.0, angle: 120.0, altitude: 30.0,
|
||||
highlight_opacity: 0.75, shadow_opacity: 0.75,
|
||||
direction: "Up".to_string(), style: "InnerBevel".to_string(),
|
||||
technique: "Smooth".to_string(), soften: 0.0,
|
||||
highlight_blend_mode: "Screen".to_string(),
|
||||
highlight_color: [255, 255, 255, 255],
|
||||
shadow_blend_mode: "Multiply".to_string(),
|
||||
shadow_color: [0, 0, 0, 255],
|
||||
},
|
||||
"Satin" => LayerStyle::Satin {
|
||||
enabled: new_enabled,
|
||||
opacity: 0.5, angle: 120.0, distance: 11.0, size: 14.0,
|
||||
color: [0, 0, 0, 255], invert: true,
|
||||
},
|
||||
"ColorOverlay" => LayerStyle::ColorOverlay {
|
||||
enabled: new_enabled,
|
||||
opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(),
|
||||
},
|
||||
"GradientOverlay" => LayerStyle::GradientOverlay {
|
||||
enabled: new_enabled,
|
||||
opacity: 1.0, blend_mode: "Normal".to_string(),
|
||||
angle: 90.0, scale: 1.0, gradient_type: 0,
|
||||
},
|
||||
"PatternOverlay" => LayerStyle::PatternOverlay {
|
||||
enabled: new_enabled,
|
||||
opacity: 1.0, blend_mode: "Normal".to_string(),
|
||||
scale: 1.0, pattern_name: "".to_string(),
|
||||
},
|
||||
"Stroke" => LayerStyle::Stroke {
|
||||
enabled: new_enabled,
|
||||
size: 3.0, position: "Outside".to_string(),
|
||||
opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(),
|
||||
},
|
||||
_ => unreachable!(),
|
||||
};
|
||||
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
|
||||
}
|
||||
}
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
Message::LayerStyleSelect(index) => {
|
||||
self.selected_style = index;
|
||||
}
|
||||
Message::LayerStyleApply => {
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
Message::LayerStyleCancel => {
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
}
|
||||
Message::LayerStyleAdd => {
|
||||
// TODO: open layer style add dialog
|
||||
// Open layer style add dialog (placeholder)
|
||||
}
|
||||
Message::LayerStyleUpdateParam(index, param, value) => {
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
if layer_id != 0 {
|
||||
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
|
||||
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
|
||||
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
|
||||
if index < disc_names.len() {
|
||||
let disc = disc_names[index];
|
||||
let existing = styles.iter().find(|s| {
|
||||
matches!((disc, s),
|
||||
("DropShadow", LayerStyle::DropShadow { .. })
|
||||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||||
| ("Satin", LayerStyle::Satin { .. })
|
||||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||||
| ("Stroke", LayerStyle::Stroke { .. })
|
||||
)
|
||||
});
|
||||
|
||||
if let Some(style) = existing {
|
||||
let mut new_style = style.clone();
|
||||
// Update the specific parameter
|
||||
match (&mut new_style, param.as_str()) {
|
||||
(LayerStyle::DropShadow { opacity, .. }, "opacity") => *opacity = value,
|
||||
(LayerStyle::DropShadow { angle, .. }, "angle") => *angle = value,
|
||||
(LayerStyle::DropShadow { distance, .. }, "distance") => *distance = value,
|
||||
(LayerStyle::DropShadow { spread, .. }, "spread") => *spread = value,
|
||||
(LayerStyle::DropShadow { size, .. }, "size") => *size = value,
|
||||
(LayerStyle::InnerShadow { opacity, .. }, "opacity") => *opacity = value,
|
||||
(LayerStyle::InnerShadow { angle, .. }, "angle") => *angle = value,
|
||||
(LayerStyle::InnerShadow { distance, .. }, "distance") => *distance = value,
|
||||
(LayerStyle::InnerShadow { spread, .. }, "spread") => *spread = value,
|
||||
(LayerStyle::InnerShadow { size, .. }, "size") => *size = value,
|
||||
(LayerStyle::OuterGlow { opacity, .. }, "opacity") => *opacity = value,
|
||||
(LayerStyle::OuterGlow { spread, .. }, "spread") => *spread = value,
|
||||
(LayerStyle::OuterGlow { size, .. }, "size") => *size = value,
|
||||
(LayerStyle::InnerGlow { opacity, .. }, "opacity") => *opacity = value,
|
||||
(LayerStyle::InnerGlow { spread, .. }, "spread") => *spread = value,
|
||||
(LayerStyle::InnerGlow { size, .. }, "size") => *size = value,
|
||||
(LayerStyle::BevelEmboss { depth, .. }, "depth") => *depth = value,
|
||||
(LayerStyle::BevelEmboss { size, .. }, "size") => *size = value,
|
||||
(LayerStyle::BevelEmboss { angle, .. }, "angle") => *angle = value,
|
||||
(LayerStyle::BevelEmboss { altitude, .. }, "altitude") => *altitude = value,
|
||||
(LayerStyle::BevelEmboss { soften, .. }, "soften") => *soften = value,
|
||||
(LayerStyle::Satin { opacity, .. }, "opacity") => *opacity = value,
|
||||
(LayerStyle::Satin { angle, .. }, "angle") => *angle = value,
|
||||
(LayerStyle::Satin { distance, .. }, "distance") => *distance = value,
|
||||
(LayerStyle::Satin { size, .. }, "size") => *size = value,
|
||||
(LayerStyle::ColorOverlay { opacity, .. }, "opacity") => *opacity = value,
|
||||
(LayerStyle::GradientOverlay { opacity, .. }, "opacity") => *opacity = value,
|
||||
(LayerStyle::GradientOverlay { angle, .. }, "angle") => *angle = value,
|
||||
(LayerStyle::GradientOverlay { scale, .. }, "scale") => *scale = value,
|
||||
(LayerStyle::PatternOverlay { opacity, .. }, "opacity") => *opacity = value,
|
||||
(LayerStyle::PatternOverlay { scale, .. }, "scale") => *scale = value,
|
||||
(LayerStyle::Stroke { size, .. }, "size") => *size = value,
|
||||
(LayerStyle::Stroke { opacity, .. }, "opacity") => *opacity = value,
|
||||
_ => {}
|
||||
}
|
||||
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
Message::LayerStyleUpdateBlendMode(index, mode) => {
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
if layer_id != 0 {
|
||||
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
|
||||
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
|
||||
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
|
||||
if index < disc_names.len() {
|
||||
let disc = disc_names[index];
|
||||
let existing = styles.iter().find(|s| {
|
||||
matches!((disc, s),
|
||||
("DropShadow", LayerStyle::DropShadow { .. })
|
||||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||||
| ("Satin", LayerStyle::Satin { .. })
|
||||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||||
| ("Stroke", LayerStyle::Stroke { .. })
|
||||
)
|
||||
});
|
||||
if let Some(style) = existing {
|
||||
let mut new_style = style.clone();
|
||||
match &mut new_style {
|
||||
LayerStyle::DropShadow { blend_mode, .. }
|
||||
| LayerStyle::InnerShadow { blend_mode, .. }
|
||||
| LayerStyle::OuterGlow { blend_mode, .. }
|
||||
| LayerStyle::InnerGlow { blend_mode, .. }
|
||||
| LayerStyle::ColorOverlay { blend_mode, .. }
|
||||
| LayerStyle::GradientOverlay { blend_mode, .. }
|
||||
| LayerStyle::PatternOverlay { blend_mode, .. }
|
||||
| LayerStyle::Stroke { blend_mode, .. } => *blend_mode = mode,
|
||||
_ => {}
|
||||
}
|
||||
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
Message::LayerStyleUpdateColor(index, new_color) => {
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
if layer_id != 0 {
|
||||
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
|
||||
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
|
||||
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
|
||||
if index < disc_names.len() {
|
||||
let disc = disc_names[index];
|
||||
let existing = styles.iter().find(|s| {
|
||||
matches!((disc, s),
|
||||
("DropShadow", LayerStyle::DropShadow { .. })
|
||||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||||
| ("Satin", LayerStyle::Satin { .. })
|
||||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||||
| ("Stroke", LayerStyle::Stroke { .. })
|
||||
)
|
||||
});
|
||||
if let Some(style) = existing {
|
||||
let mut updated_style = style.clone();
|
||||
match &mut updated_style {
|
||||
LayerStyle::DropShadow { color, .. }
|
||||
| LayerStyle::InnerShadow { color, .. }
|
||||
| LayerStyle::OuterGlow { color, .. }
|
||||
| LayerStyle::InnerGlow { color, .. }
|
||||
| LayerStyle::ColorOverlay { color, .. }
|
||||
| LayerStyle::Satin { color, .. }
|
||||
| LayerStyle::Stroke { color, .. } => *color = new_color,
|
||||
LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color = new_color,
|
||||
_ => {}
|
||||
}
|
||||
self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update the HSL state for the picker
|
||||
let r = new_color[0] as f32 / 255.0;
|
||||
let g = new_color[1] as f32 / 255.0;
|
||||
let b = new_color[2] as f32 / 255.0;
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) };
|
||||
let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 };
|
||||
self.style_color_hsl = (h, s, l);
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
Message::ShowStyleColorPicker(idx) => {
|
||||
self.show_style_color_picker = true;
|
||||
self.style_color_picker_idx = idx;
|
||||
// Initialize HSL from current color
|
||||
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
|
||||
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
if layer_id != 0 && idx < disc_names.len() {
|
||||
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
|
||||
let disc = disc_names[idx];
|
||||
let color = styles.iter().find(|s| {
|
||||
matches!((disc, s),
|
||||
("DropShadow", LayerStyle::DropShadow { .. })
|
||||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||||
| ("Satin", LayerStyle::Satin { .. })
|
||||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||||
| ("Stroke", LayerStyle::Stroke { .. })
|
||||
)
|
||||
}).map(|s| match s {
|
||||
LayerStyle::DropShadow { color, .. }
|
||||
| LayerStyle::InnerShadow { color, .. }
|
||||
| LayerStyle::OuterGlow { color, .. }
|
||||
| LayerStyle::InnerGlow { color, .. }
|
||||
| LayerStyle::ColorOverlay { color, .. }
|
||||
| LayerStyle::Satin { color, .. }
|
||||
| LayerStyle::Stroke { color, .. } => *color,
|
||||
LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color,
|
||||
_ => [0, 0, 0, 255],
|
||||
}).unwrap_or([0, 0, 0, 255]);
|
||||
let r = color[0] as f32 / 255.0;
|
||||
let g = color[1] as f32 / 255.0;
|
||||
let b = color[2] as f32 / 255.0;
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) };
|
||||
let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 };
|
||||
self.style_color_hsl = (h, s, l);
|
||||
}
|
||||
}
|
||||
Message::HideStyleColorPicker => {
|
||||
self.show_style_color_picker = false;
|
||||
}
|
||||
Message::OpenLayerStyleDialog => {
|
||||
self.active_dialog = ActiveDialog::LayerStyleDialog;
|
||||
self.layer_style_offset = (0.0, 0.0);
|
||||
self.layer_style_dragging = false;
|
||||
self.layer_style_drag_start = None;
|
||||
}
|
||||
Message::LayerStyleDialogDragStart(_x, _y) => {
|
||||
// Drag started — begin tracking mouse movement.
|
||||
// The actual offset computation uses delta from the last position.
|
||||
self.layer_style_dragging = true;
|
||||
}
|
||||
Message::LayerStyleDialogDragMove(x, y) => {
|
||||
if self.layer_style_dragging {
|
||||
if let Some((lx, ly)) = self.layer_style_drag_start {
|
||||
// Compute delta from last position and add to offset
|
||||
let dx = x - lx;
|
||||
let dy = y - ly;
|
||||
self.layer_style_offset.0 += dx;
|
||||
self.layer_style_offset.1 += dy;
|
||||
}
|
||||
self.layer_style_drag_start = Some((x, y));
|
||||
}
|
||||
}
|
||||
Message::LayerStyleDialogDragEnd => {
|
||||
self.layer_style_dragging = false;
|
||||
self.layer_style_drag_start = None;
|
||||
}
|
||||
|
||||
// ── Brushes ───────────────────────────────────
|
||||
@@ -1317,7 +1803,18 @@ impl HcieIcedApp {
|
||||
async {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Open Image")
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "psd", "kra", "hcie"])
|
||||
.add_filter("All Supported Types", &[
|
||||
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
||||
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
||||
"hcie", "psd", "kra",
|
||||
])
|
||||
.add_filter("Images", &[
|
||||
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
||||
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
||||
])
|
||||
.add_filter("HCIE Native", &["hcie"])
|
||||
.add_filter("Photoshop", &["psd"])
|
||||
.add_filter("Krita", &["kra"])
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|handle| handle.path().to_path_buf())
|
||||
@@ -1330,9 +1827,22 @@ impl HcieIcedApp {
|
||||
async {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Save As")
|
||||
.add_filter("HCIE Native", &["hcie"])
|
||||
.add_filter("Photoshop", &["psd"])
|
||||
.add_filter("Krita", &["kra"])
|
||||
.add_filter("PNG", &["png"])
|
||||
.add_filter("JPEG", &["jpg", "jpeg"])
|
||||
.add_filter("WebP", &["webp"])
|
||||
.add_filter("AVIF", &["avif"])
|
||||
.add_filter("BMP", &["bmp"])
|
||||
.add_filter("GIF", &["gif"])
|
||||
.add_filter("TIFF", &["tiff", "tif"])
|
||||
.add_filter("TGA", &["tga"])
|
||||
.add_filter("OpenEXR", &["exr"])
|
||||
.add_filter("All Supported Types", &[
|
||||
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
|
||||
"avif", "tga", "exr", "psd", "kra", "hcie",
|
||||
])
|
||||
.save_file()
|
||||
.await
|
||||
.map(|handle| handle.path().to_path_buf())
|
||||
@@ -1348,14 +1858,22 @@ impl HcieIcedApp {
|
||||
.to_lowercase();
|
||||
|
||||
if self.active_dialog == ActiveDialog::None {
|
||||
// Open file
|
||||
// Open file — route multi-layer formats to dedicated importers
|
||||
let engine = &mut self.documents[self.active_doc].engine;
|
||||
match engine.open_image(&path_str) {
|
||||
let result = match ext.as_str() {
|
||||
"psd" => engine.import_psd(&path_str),
|
||||
"kra" => engine.import_kra(&path_str),
|
||||
"hcie" => engine.load_native(&path_str),
|
||||
_ => engine.open_image(&path_str),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
||||
self.documents[self.active_doc].name = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
self.documents[self.active_doc].source_path = Some(path);
|
||||
self.documents[self.active_doc].source_path = Some(path.clone());
|
||||
self.add_recent_file(&path);
|
||||
self.documents[self.active_doc].full_upload.replace(true);
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
@@ -1363,9 +1881,15 @@ impl HcieIcedApp {
|
||||
Err(e) => log::error!("Failed to open: {}", e),
|
||||
}
|
||||
} else {
|
||||
// Save file
|
||||
// Save file — route multi-layer formats to dedicated exporters
|
||||
let engine = &self.documents[self.active_doc].engine;
|
||||
match engine.save_as(&path_str, &ext) {
|
||||
let result = match ext.as_str() {
|
||||
"hcie" => engine.save_native(&path_str),
|
||||
"psd" => engine.export_psd(&path_str),
|
||||
"kra" => engine.export_kra(&path_str),
|
||||
_ => engine.save_as(&path_str, &ext),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => log::info!("Saved to {}", path_str),
|
||||
Err(e) => log::error!("Failed to save: {}", e),
|
||||
}
|
||||
@@ -1445,7 +1969,18 @@ impl HcieIcedApp {
|
||||
async {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Open Image")
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "psd", "kra", "hcie"])
|
||||
.add_filter("All Supported Types", &[
|
||||
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
||||
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
||||
"hcie", "psd", "kra",
|
||||
])
|
||||
.add_filter("Images", &[
|
||||
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
|
||||
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
|
||||
])
|
||||
.add_filter("HCIE Native", &["hcie"])
|
||||
.add_filter("Photoshop", &["psd"])
|
||||
.add_filter("Krita", &["kra"])
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|handle| handle.path().to_path_buf())
|
||||
@@ -1467,9 +2002,22 @@ impl HcieIcedApp {
|
||||
async {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Save As")
|
||||
.add_filter("HCIE Native", &["hcie"])
|
||||
.add_filter("Photoshop", &["psd"])
|
||||
.add_filter("Krita", &["kra"])
|
||||
.add_filter("PNG", &["png"])
|
||||
.add_filter("JPEG", &["jpg", "jpeg"])
|
||||
.add_filter("WebP", &["webp"])
|
||||
.add_filter("AVIF", &["avif"])
|
||||
.add_filter("BMP", &["bmp"])
|
||||
.add_filter("GIF", &["gif"])
|
||||
.add_filter("TIFF", &["tiff", "tif"])
|
||||
.add_filter("TGA", &["tga"])
|
||||
.add_filter("OpenEXR", &["exr"])
|
||||
.add_filter("All Supported Types", &[
|
||||
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
|
||||
"avif", "tga", "exr", "psd", "kra", "hcie",
|
||||
])
|
||||
.save_file()
|
||||
.await
|
||||
.map(|handle| handle.path().to_path_buf())
|
||||
@@ -1493,30 +2041,75 @@ impl HcieIcedApp {
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
}
|
||||
}
|
||||
Message::OpenRecentFile(idx) => {
|
||||
self.active_menu = None;
|
||||
// Clone the path to avoid borrow conflict
|
||||
let path_str = self.recent_files.get(idx).map(|e| e.path.clone());
|
||||
if let Some(path_str) = path_str {
|
||||
let path = std::path::PathBuf::from(&path_str);
|
||||
if path.exists() {
|
||||
let ext = path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let result = match ext.as_str() {
|
||||
"psd" => self.documents[self.active_doc].engine.import_psd(&path_str),
|
||||
"kra" => self.documents[self.active_doc].engine.import_kra(&path_str),
|
||||
"hcie" => self.documents[self.active_doc].engine.load_native(&path_str),
|
||||
_ => self.documents[self.active_doc].engine.open_image(&path_str),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
||||
self.documents[self.active_doc].name = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
self.documents[self.active_doc].source_path = Some(path.clone());
|
||||
self.add_recent_file(&path);
|
||||
let composite = self.documents[self.active_doc].engine.get_composite_pixels();
|
||||
self.documents[self.active_doc].composite_raw = composite;
|
||||
self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(self.documents[self.active_doc].composite_raw.clone());
|
||||
self.documents[self.active_doc].full_upload.replace(true);
|
||||
self.documents[self.active_doc].render_generation = self.documents[self.active_doc].render_generation.wrapping_add(1);
|
||||
self.documents[self.active_doc].cached_layers = self.documents[self.active_doc].engine.layer_infos();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
Err(e) => log::error!("Failed to open recent file: {}", e),
|
||||
}
|
||||
} else {
|
||||
self.recent_files.retain(|e| e.path != path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::ClearRecentFiles => {
|
||||
self.active_menu = None;
|
||||
self.recent_files.clear();
|
||||
save_recent_files(&self.recent_files);
|
||||
}
|
||||
Message::MenuAction(menu_idx, item_idx) => {
|
||||
self.active_menu = None;
|
||||
// Dispatch the appropriate action based on menu and item indices
|
||||
match (menu_idx, item_idx) {
|
||||
// ── File menu (0) ──
|
||||
(0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New
|
||||
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open
|
||||
// (0, 2) = separator
|
||||
(0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save
|
||||
(0, 4) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save As
|
||||
// (0, 5) = separator
|
||||
// (0, 6) = Import SVG — TODO
|
||||
// (0, 7) = Import Brushes — TODO
|
||||
// (0, 8) = separator
|
||||
(0, 9) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG (placeholder → Save As)
|
||||
(0, 10) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG (placeholder → Save As)
|
||||
// (0, 11) = separator
|
||||
(0, 12) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)), // Close
|
||||
// (0, 13) = Exit
|
||||
(0, 13) => {
|
||||
(0, _) if item_idx >= 2 => {
|
||||
let recent_count = self.recent_files.len().min(10);
|
||||
let static_base = if recent_count > 0 { 4 + recent_count + 3 } else { 2 };
|
||||
if item_idx >= static_base {
|
||||
match item_idx - static_base {
|
||||
0 => return Task::perform(async {}, |_| Message::SaveFileAs),
|
||||
1 => return Task::perform(async {}, |_| Message::SaveFileAs),
|
||||
6 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG
|
||||
7 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG
|
||||
9 => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)),
|
||||
10 => {
|
||||
if let Some(id) = self.window_id {
|
||||
return iced::window::close(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit menu (1) ──
|
||||
(1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo
|
||||
@@ -1584,9 +2177,7 @@ impl HcieIcedApp {
|
||||
(4, 4) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down
|
||||
(4, 5) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image
|
||||
// (4, 6) = separator
|
||||
// (4, 7) = Clear Layer — TODO
|
||||
// (4, 8) = separator
|
||||
// (4, 9) = Layer Styles — TODO
|
||||
(4, 7) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Styles
|
||||
// (4, 10) = separator
|
||||
// (4, 11) = Align Left — TODO
|
||||
// (4, 12) = Align Center — TODO
|
||||
@@ -1775,6 +2366,20 @@ impl HcieIcedApp {
|
||||
1.0,
|
||||
100.0,
|
||||
),
|
||||
ActiveDialog::LayerStyleDialog => {
|
||||
let doc = &self.documents[self.active_doc];
|
||||
let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id());
|
||||
let styles: &'static [hcie_engine_api::LayerStyle] = Box::leak(styles.into_boxed_slice());
|
||||
crate::panels::layer_styles::view(
|
||||
styles,
|
||||
self.selected_style,
|
||||
colors,
|
||||
self.show_style_color_picker,
|
||||
self.style_color_picker_idx,
|
||||
self.style_color_hsl,
|
||||
self.layer_style_offset,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Stack main content with overlays (dialog + menu dropdown).
|
||||
@@ -1788,7 +2393,7 @@ impl HcieIcedApp {
|
||||
if has_dialog {
|
||||
stack = stack.push(dialog_overlay);
|
||||
}
|
||||
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu) {
|
||||
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files) {
|
||||
stack = stack.push(menu_overlay);
|
||||
}
|
||||
let _elapsed = view_start.elapsed();
|
||||
@@ -1871,6 +2476,27 @@ impl HcieIcedApp {
|
||||
}
|
||||
});
|
||||
|
||||
iced::Subscription::batch(vec![keyboard])
|
||||
// Mouse events for dialog dragging
|
||||
let mouse = iced::event::listen_with(|event, _status, _id| {
|
||||
if let iced::Event::Mouse(mouse_event) = event {
|
||||
match mouse_event {
|
||||
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => {
|
||||
// Drag start is handled by mouse_area on_press
|
||||
None
|
||||
}
|
||||
iced::mouse::Event::CursorMoved { position } => {
|
||||
Some(Message::LayerStyleDialogDragMove(position.x, position.y))
|
||||
}
|
||||
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
|
||||
Some(Message::LayerStyleDialogDragEnd)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
iced::Subscription::batch(vec![keyboard, mouse])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +1,347 @@
|
||||
//! Basic color picker — RGB sliders and palette grid.
|
||||
//!
|
||||
//! Provides a color selection interface with RGB sliders
|
||||
//! and a predefined palette grid. Palette swatches are clickable.
|
||||
//! Color picker — matches the egui version with:
|
||||
//! - Primary/Secondary swatch buttons + swap button
|
||||
//! - Editable hex input field
|
||||
//! - Tab bar: W (Color Wheel), H (HSL Sliders), G (Palette Grid)
|
||||
//! - Color Wheel: hue ring + saturation/lightness square
|
||||
//! - HSL sliders
|
||||
//! - 10x10 palette grid
|
||||
//! - Recent colors
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{column, container, horizontal_rule, row, slider, text};
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input};
|
||||
use iced::{Element, Length, Padding};
|
||||
|
||||
/// A predefined color palette.
|
||||
const PALETTE: &[[u8; 3]] = &[
|
||||
[0, 0, 0], // Black
|
||||
[255, 255, 255], // White
|
||||
[255, 0, 0], // Red
|
||||
[0, 255, 0], // Green
|
||||
[0, 0, 255], // Blue
|
||||
[255, 255, 0], // Yellow
|
||||
[255, 0, 255], // Magenta
|
||||
[0, 255, 255], // Cyan
|
||||
[128, 0, 0], // Dark Red
|
||||
[128, 128, 0], // Olive
|
||||
[0, 128, 0], // Dark Green
|
||||
[0, 128, 128], // Teal
|
||||
[0, 0, 128], // Navy
|
||||
[128, 0, 128], // Purple
|
||||
[192, 192, 192], // Silver
|
||||
[128, 128, 128], // Gray
|
||||
[255, 128, 128], // Light Red
|
||||
[255, 192, 128], // Orange
|
||||
[255, 255, 128], // Light Yellow
|
||||
[128, 255, 128], // Light Green
|
||||
[128, 255, 255], // Light Cyan
|
||||
[128, 128, 255], // Light Blue
|
||||
[192, 128, 255], // Light Purple
|
||||
[255, 128, 192], // Pink
|
||||
];
|
||||
/// A predefined color palette (10x10 grid) — generated algorithmically to match egui.
|
||||
fn palette_color(row: usize, col: usize) -> [u8; 3] {
|
||||
let lightness = (95.0 - row as f32 * 80.0 / 9.0) / 100.0;
|
||||
if col == 0 {
|
||||
// Grayscale column
|
||||
let v = (lightness * 255.0) as u8;
|
||||
[v, v, v]
|
||||
} else {
|
||||
// Hue columns at saturation 0.85
|
||||
let hue = col as f32 * 360.0 / 10.0;
|
||||
let (r, g, b) = hsl_to_rgb(hue, 0.85, lightness);
|
||||
[r, g, b]
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the color picker element.
|
||||
pub fn view<'a>(fg_color: &'a [u8; 4], _bg_color: &'a [u8; 4]) -> Element<'a, Message> {
|
||||
/// Convert RGB to HSL.
|
||||
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||
let r = r as f32 / 255.0;
|
||||
let g = g as f32 / 255.0;
|
||||
let b = b as f32 / 255.0;
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min { return (0.0, 0.0, l); }
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
|
||||
} else if max == g {
|
||||
((b - r) / d + 2.0) / 6.0
|
||||
} else {
|
||||
((r - g) / d + 4.0) / 6.0
|
||||
};
|
||||
(h * 360.0, s, l)
|
||||
}
|
||||
|
||||
/// Convert HSL to RGB.
|
||||
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||
let h = h / 360.0;
|
||||
let (r, g, b) = if s == 0.0 {
|
||||
(l, l, l)
|
||||
} else {
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
(hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0))
|
||||
};
|
||||
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
|
||||
}
|
||||
|
||||
fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 {
|
||||
let t = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t };
|
||||
if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t }
|
||||
else if t < 1.0 / 2.0 { q }
|
||||
else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 }
|
||||
else { p }
|
||||
}
|
||||
|
||||
/// Build the full color picker panel.
|
||||
pub fn view<'a>(
|
||||
fg_color: &'a [u8; 4],
|
||||
bg_color: &'a [u8; 4],
|
||||
recent_colors: &'a [[u8; 4]],
|
||||
color_tab: usize,
|
||||
) -> Element<'a, Message> {
|
||||
let r = fg_color[0];
|
||||
let g = fg_color[1];
|
||||
let b = fg_color[2];
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
|
||||
let r_slider = slider(0..=255, r, move |v| Message::FgColorChanged([v, g, b, 255]));
|
||||
let g_slider = slider(0..=255, g, move |v| Message::FgColorChanged([r, v, b, 255]));
|
||||
let b_slider = slider(0..=255, b, move |v| Message::FgColorChanged([r, g, v, 255]));
|
||||
|
||||
let sliders = column![
|
||||
row![text("R").size(10), r_slider].spacing(4),
|
||||
row![text("G").size(10), g_slider].spacing(4),
|
||||
row![text("B").size(10), b_slider].spacing(4),
|
||||
]
|
||||
.spacing(2)
|
||||
.padding(Padding::from([4, 4]));
|
||||
|
||||
// Color swatch preview
|
||||
// ── 1. Primary/Secondary swatch + swap ──
|
||||
let fg = fg_color;
|
||||
let preview = container(text(""))
|
||||
.width(Length::Fill)
|
||||
.height(32)
|
||||
let bg = bg_color;
|
||||
let primary_swatch = container(text(""))
|
||||
.width(20)
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
fg[0] as f32 / 255.0,
|
||||
fg[1] as f32 / 255.0,
|
||||
fg[2] as f32 / 255.0,
|
||||
fg[3] as f32 / 255.0,
|
||||
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(4).color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
|
||||
..Default::default()
|
||||
});
|
||||
let secondary_swatch = container(text(""))
|
||||
.width(20)
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
|
||||
..Default::default()
|
||||
});
|
||||
let swap_btn = button(text("\u{21C5}").size(11))
|
||||
.on_press(Message::SwapColors)
|
||||
.padding([2, 4]);
|
||||
let swatch_row = row![primary_swatch, secondary_swatch, swap_btn].spacing(4).align_y(iced::Alignment::Center);
|
||||
|
||||
// Palette grid — each swatch is clickable
|
||||
let mut palette_grid = column![].spacing(1);
|
||||
for row_colors in PALETTE.chunks(8) {
|
||||
let mut row_widgets = row![].spacing(1);
|
||||
for &color in row_colors {
|
||||
// ── 2. Hex input ──
|
||||
let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b);
|
||||
let hex_input = text_input("#", &hex_str)
|
||||
.on_input(|input| {
|
||||
let clean = input.trim_start_matches('#').to_uppercase();
|
||||
if clean.len() == 6 {
|
||||
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||
u8::from_str_radix(&clean[0..2], 16),
|
||||
u8::from_str_radix(&clean[2..4], 16),
|
||||
u8::from_str_radix(&clean[4..6], 16),
|
||||
) {
|
||||
return Message::FgColorChanged([r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
Message::NoOp
|
||||
})
|
||||
.width(70)
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(10);
|
||||
let hex_row = row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center);
|
||||
|
||||
// ── 3. Tab bar ──
|
||||
let tab_w = 30;
|
||||
let make_tab = |label: &'static str, idx: usize, color_tab: usize| {
|
||||
button(text(label).size(10)).padding([2, tab_w / 2 - 4])
|
||||
.on_press(Message::ColorTabChanged(idx))
|
||||
.style(move |_theme, _status| iced::widget::button::Style {
|
||||
background: if color_tab == idx {
|
||||
Some(iced::Background::Color(iced::Color::from_rgb(0.35, 0.35, 0.35)))
|
||||
} else {
|
||||
Some(iced::Background::Color(iced::Color::from_rgb(0.22, 0.22, 0.22)))
|
||||
},
|
||||
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
|
||||
border: iced::Border::default().rounded(2),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let tabs = row![
|
||||
make_tab("W", 0, color_tab),
|
||||
make_tab("H", 1, color_tab),
|
||||
make_tab("G", 2, color_tab),
|
||||
].spacing(1);
|
||||
|
||||
// ── 4. Tab content ──
|
||||
let tab_content: Element<'_, Message> = match color_tab {
|
||||
0 => build_wheel_view(h, s, l),
|
||||
1 => build_slider_view(h, s, l),
|
||||
_ => build_grid_view(),
|
||||
};
|
||||
|
||||
// ── 5. Recent colors ──
|
||||
let mut recent_items: Vec<Element<'_, Message>> = Vec::new();
|
||||
for &color in recent_colors.iter().take(20) {
|
||||
let c = color;
|
||||
let swatch = container(text(""))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.width(13)
|
||||
.height(13)
|
||||
.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,
|
||||
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()
|
||||
});
|
||||
|
||||
// Make swatch clickable
|
||||
let clickable = iced::widget::mouse_area(swatch)
|
||||
.on_press(Message::FgColorChanged([c[0], c[1], c[2], 255]))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
recent_items.push(clickable.into());
|
||||
}
|
||||
let recent_row: Element<'_, Message> = if recent_items.is_empty() {
|
||||
text("").into()
|
||||
} else {
|
||||
row(recent_items).spacing(2).into()
|
||||
};
|
||||
|
||||
column![
|
||||
text("Color").size(11).font(iced::Font::MONOSPACE),
|
||||
swatch_row,
|
||||
hex_row,
|
||||
tabs,
|
||||
tab_content,
|
||||
horizontal_rule(1),
|
||||
text("Recent").size(9),
|
||||
recent_row,
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.spacing(3)
|
||||
.padding(Padding::from([4, 4]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the HSL color wheel view (tab W).
|
||||
///
|
||||
/// Uses a hue ring made of colored cells and an SL picker grid.
|
||||
fn build_wheel_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> {
|
||||
// Hue ring: 36 cells, each 10 degrees
|
||||
let cell_size = 10;
|
||||
let mut hue_ring = row![].spacing(0);
|
||||
for i in 0..36 {
|
||||
let hue = i as f32 * 10.0;
|
||||
let (r, g, b) = hsl_to_rgb(hue, 1.0, 0.5);
|
||||
let is_current = (h - hue).abs() < 5.0 || (h - hue).abs() > 355.0;
|
||||
let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
|
||||
let cell = container(text(""))
|
||||
.width(cell_size)
|
||||
.height(cell_size)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(cell_color)),
|
||||
border: if is_current {
|
||||
iced::Border::default().color(iced::Color::WHITE).width(1)
|
||||
} else {
|
||||
iced::Border::default()
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let h_val = hue;
|
||||
let s_val = s;
|
||||
let l_val = l;
|
||||
let clickable = iced::widget::mouse_area(cell)
|
||||
.on_press(Message::FgColorChanged({
|
||||
let (r, g, b) = hsl_to_rgb(h_val, s_val, l_val);
|
||||
[r, g, b, 255]
|
||||
}))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
hue_ring = hue_ring.push(clickable);
|
||||
}
|
||||
|
||||
// SL picker grid: 12 columns (saturation) x 12 rows (lightness)
|
||||
let sl_size = 10;
|
||||
let mut sl_grid = column![].spacing(0);
|
||||
for row_idx in 0..12 {
|
||||
let lightness = row_idx as f32 / 11.0;
|
||||
let mut sl_row = row![].spacing(0);
|
||||
for col_idx in 0..12 {
|
||||
let sat = col_idx as f32 / 11.0;
|
||||
let (r, g, b) = hsl_to_rgb(h, sat, lightness);
|
||||
let is_current = (s - sat).abs() < 0.05 && (l - lightness).abs() < 0.05;
|
||||
let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
|
||||
let cell = container(text(""))
|
||||
.width(sl_size)
|
||||
.height(sl_size)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(cell_color)),
|
||||
border: if is_current {
|
||||
iced::Border::default().color(iced::Color::WHITE).width(1)
|
||||
} else {
|
||||
iced::Border::default()
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let h_val = h;
|
||||
let s_val = sat;
|
||||
let l_val = lightness;
|
||||
let clickable = iced::widget::mouse_area(cell)
|
||||
.on_press(Message::FgColorChanged({
|
||||
let (r, g, b) = hsl_to_rgb(h_val, s_val, l_val);
|
||||
[r, g, b, 255]
|
||||
}))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
sl_row = sl_row.push(clickable);
|
||||
}
|
||||
sl_grid = sl_grid.push(sl_row);
|
||||
}
|
||||
|
||||
// HSL readout
|
||||
let readout = text(format!("H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", h, s * 100.0, l * 100.0))
|
||||
.size(9).font(iced::Font::MONOSPACE);
|
||||
|
||||
column![
|
||||
text("Hue").size(9),
|
||||
hue_ring,
|
||||
text("Saturation / Lightness").size(9),
|
||||
sl_grid,
|
||||
readout,
|
||||
]
|
||||
.spacing(3)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the HSL sliders view (tab H).
|
||||
fn build_slider_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> {
|
||||
let h_slider = slider(0.0..=360.0, h, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(v, s, l);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
}).step(1.0);
|
||||
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(h, v / 100.0, l);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
}).step(1.0);
|
||||
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(h, s, v / 100.0);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
}).step(1.0);
|
||||
|
||||
column![
|
||||
row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(2),
|
||||
row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(2),
|
||||
row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(2),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the palette grid view (tab G).
|
||||
fn build_grid_view<'a>() -> Element<'a, Message> {
|
||||
let mut palette_grid = column![].spacing(1);
|
||||
for row_idx in 0..10 {
|
||||
let mut row_widgets = row![].spacing(1);
|
||||
for col_idx in 0..10 {
|
||||
let c = palette_color(row_idx, col_idx);
|
||||
let swatch = container(text(""))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(1),
|
||||
..Default::default()
|
||||
});
|
||||
let clickable = iced::widget::mouse_area(swatch)
|
||||
.on_press(Message::FgColorChanged([c[0], c[1], c[2], 255]))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
row_widgets = row_widgets.push(clickable);
|
||||
}
|
||||
palette_grid = palette_grid.push(row_widgets);
|
||||
}
|
||||
|
||||
let palette = column![
|
||||
column![
|
||||
text("Palette").size(10),
|
||||
palette_grid,
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(Padding::from([4, 4]));
|
||||
|
||||
column![
|
||||
text("Color").size(12).font(iced::Font::MONOSPACE),
|
||||
preview,
|
||||
sliders,
|
||||
horizontal_rule(1),
|
||||
palette,
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.spacing(4)
|
||||
.padding(Padding::from([4, 4]))
|
||||
.spacing(2)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -113,12 +113,12 @@ impl DockState {
|
||||
b: Box::new(Configuration::Pane(PaneType::History)),
|
||||
};
|
||||
|
||||
// Right column 2: Layers (top) / Properties (bottom)
|
||||
// Right column 2: Layers (top) / LayerStyles (bottom)
|
||||
let right_col2 = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Horizontal,
|
||||
ratio: 0.5,
|
||||
ratio: 0.6,
|
||||
a: Box::new(Configuration::Pane(PaneType::Layers)),
|
||||
b: Box::new(Configuration::Pane(PaneType::Properties)),
|
||||
b: Box::new(Configuration::Pane(PaneType::LayerStyles)),
|
||||
};
|
||||
|
||||
// Right side: right_col1 (left) / right_col2 (right) — vertical split
|
||||
@@ -130,19 +130,19 @@ impl DockState {
|
||||
};
|
||||
|
||||
// Center + right: Canvas (left) / right_side (right) — vertical split
|
||||
// Canvas gets ~80% of the remaining space after left column.
|
||||
// Canvas gets ~65% — right panels need room for layers + styles
|
||||
let center_right = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Vertical,
|
||||
ratio: 0.80,
|
||||
ratio: 0.65,
|
||||
a: Box::new(Configuration::Pane(PaneType::Canvas)),
|
||||
b: Box::new(right_side),
|
||||
};
|
||||
|
||||
// Root: left_column (left) / center_right (right) — vertical split
|
||||
// Left column gets ~17% of total width (matches egui default).
|
||||
// Left column gets ~14% of total width.
|
||||
let root_config = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Vertical,
|
||||
ratio: 0.17,
|
||||
ratio: 0.14,
|
||||
a: Box::new(left_column),
|
||||
b: Box::new(center_right),
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn dock_view<'a>(
|
||||
// Tools is no longer in the dock — toolbox is a fixed strip on the left
|
||||
PaneType::Layers => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), colors)
|
||||
crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), &doc.engine, colors)
|
||||
}
|
||||
PaneType::History => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
@@ -69,7 +69,7 @@ pub fn dock_view<'a>(
|
||||
crate::panels::filters::view(app.selected_filter, colors)
|
||||
}
|
||||
PaneType::ColorPicker => {
|
||||
crate::color_picker::view(&app.fg_color, &app.bg_color)
|
||||
crate::color_picker::view(&app.fg_color, &app.bg_color, &app.recent_colors, app.color_tab)
|
||||
}
|
||||
PaneType::Properties => {
|
||||
crate::panels::properties::view(
|
||||
@@ -89,7 +89,18 @@ pub fn dock_view<'a>(
|
||||
crate::panels::ai_chat_panel::view("", &[], colors)
|
||||
}
|
||||
PaneType::LayerStyles => {
|
||||
crate::panels::layer_styles::view(&[], colors)
|
||||
let doc = &app.documents[app.active_doc];
|
||||
let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id());
|
||||
// Leak the Vec so the Element can borrow it for the full lifetime.
|
||||
// The leaked memory is reclaimed on next frame when the panel rebuilds.
|
||||
let styles: &'static [hcie_engine_api::LayerStyle] = Box::leak(styles.into_boxed_slice());
|
||||
crate::panels::layer_styles::view(
|
||||
styles, app.selected_style, colors,
|
||||
app.show_style_color_picker,
|
||||
app.style_color_picker_idx,
|
||||
app.style_color_hsl,
|
||||
app.layer_style_offset,
|
||||
)
|
||||
}
|
||||
PaneType::LayerDetails => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Layer details panel — detailed information about the active layer.
|
||||
//! Uses ThemeColors for consistent styling.
|
||||
//!
|
||||
//! Shows the complete property set of the currently selected layer including
|
||||
//! name, type, blend mode, opacity, visibility, lock state, effects, and
|
||||
//! clipping mask status.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
@@ -21,27 +24,34 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo
|
||||
row![text("Name:").size(10), text(&layer.name).size(11)].spacing(4),
|
||||
row![text("ID:").size(10), text(format!("{}", layer.id)).size(10)].spacing(4),
|
||||
row![text("Type:").size(10), text(layer_type).size(10)].spacing(4),
|
||||
row![text("Size:").size(10), text(format!("{}x{}", layer.width, layer.height)).size(10)].spacing(4),
|
||||
horizontal_rule(1),
|
||||
row![text("Blend:").size(10), text(blend_mode).size(10)].spacing(4),
|
||||
row![text("Opacity:").size(10), text(format!("{:.0}%", layer.opacity * 100.0)).size(10)].spacing(4),
|
||||
row![text("Visible:").size(10), text(if layer.visible { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
row![text("Locked:").size(10), text(if layer.locked { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
row![text("Clip Mask:").size(10), text(if layer.clipping_mask { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
row![text("Parent:").size(10), text(format!("{:?}", layer.parent_id)).size(10)].spacing(4),
|
||||
horizontal_rule(1),
|
||||
row![text("Effects:").size(10), text(if layer.has_effects { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
row![text("Vector:").size(10), text(if layer.has_vector_shapes {
|
||||
format!("{} shapes", layer.shape_count)
|
||||
} else { "None".to_string() }).size(10)].spacing(4),
|
||||
row![text("Collapsed:").size(10), text(if layer.collapsed { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
]
|
||||
.spacing(4)
|
||||
.spacing(3)
|
||||
.into()
|
||||
}
|
||||
None => text("No layer selected").size(11).into(),
|
||||
};
|
||||
|
||||
let panel = column![
|
||||
text("Layer Details").size(13).font(iced::Font::MONOSPACE),
|
||||
text("Layer Details").size(12).font(iced::Font::MONOSPACE),
|
||||
horizontal_rule(1),
|
||||
scrollable(content).height(Length::Fill),
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(8);
|
||||
.spacing(3)
|
||||
.padding(6);
|
||||
|
||||
container(panel)
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -1,69 +1,518 @@
|
||||
//! Layer styles panel — layer effects editor.
|
||||
//! Uses ThemeColors for consistent styling.
|
||||
//! Layer Style dialog — floating modal with Photoshop-like layout.
|
||||
//!
|
||||
//! Left sidebar: Style list with checkboxes
|
||||
//! Right side: Parameters for the selected style (Blend Mode, Opacity, Color, etc.)
|
||||
//! Bottom: OK/Cancel buttons
|
||||
//! Includes a clickable color swatch with HSL color picker popup.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::LayerStyle;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text};
|
||||
use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the layer styles panel.
|
||||
pub fn view<'a>(styles_list: &'a [LayerStyle], colors: ThemeColors) -> Element<'a, Message> {
|
||||
let mut style_list = column![].spacing(4);
|
||||
/// All 10 standard Photoshop layer style types.
|
||||
const STYLE_DEFS: &[(&str, &str, [f32; 3])] = &[
|
||||
("DropShadow", "Drop Shadow", [0.86, 0.39, 0.39]),
|
||||
("InnerShadow", "Inner Shadow", [0.71, 0.47, 0.47]),
|
||||
("OuterGlow", "Outer Glow", [0.86, 0.86, 0.39]),
|
||||
("InnerGlow", "Inner Glow", [0.71, 0.71, 0.39]),
|
||||
("BevelEmboss", "Bevel & Emboss", [0.39, 0.71, 0.86]),
|
||||
("Satin", "Satin", [0.71, 0.55, 0.86]),
|
||||
("ColorOverlay", "Color Overlay", [0.5, 0.5, 0.5]),
|
||||
("GradientOverlay", "Gradient Overlay", [0.5, 0.5, 0.5]),
|
||||
("PatternOverlay", "Pattern Overlay", [0.5, 0.5, 0.5]),
|
||||
("Stroke", "Stroke", [0.5, 0.5, 0.5]),
|
||||
];
|
||||
|
||||
if styles_list.is_empty() {
|
||||
style_list = style_list.push(text("No layer styles").size(11));
|
||||
/// Blend mode options for the dropdown.
|
||||
const BLEND_MODES: &[&str] = &[
|
||||
"Normal", "Dissolve", "Darken", "Multiply", "Color Burn", "Linear Burn",
|
||||
"Darker Color", "Lighten", "Screen", "Color Dodge", "Linear Dodge",
|
||||
"Lighter Color", "Overlay", "Soft Light", "Hard Light", "Vivid Light",
|
||||
"Linear Light", "Pin Light", "Hard Mix", "Difference", "Exclusion",
|
||||
"Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity",
|
||||
];
|
||||
|
||||
/// Check if a LayerStyle matches a discriminant name.
|
||||
fn style_matches(discriminant: &str, style: &LayerStyle) -> bool {
|
||||
matches!(
|
||||
(discriminant, style),
|
||||
("DropShadow", LayerStyle::DropShadow { .. })
|
||||
| ("InnerShadow", LayerStyle::InnerShadow { .. })
|
||||
| ("OuterGlow", LayerStyle::OuterGlow { .. })
|
||||
| ("InnerGlow", LayerStyle::InnerGlow { .. })
|
||||
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
|
||||
| ("Satin", LayerStyle::Satin { .. })
|
||||
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
|
||||
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
|
||||
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
|
||||
| ("Stroke", LayerStyle::Stroke { .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Get enabled state for a style.
|
||||
fn style_enabled(discriminant: &str, styles: &[LayerStyle]) -> bool {
|
||||
styles.iter().find(|s| style_matches(discriminant, s))
|
||||
.map(|s| match s {
|
||||
LayerStyle::DropShadow { enabled, .. }
|
||||
| LayerStyle::InnerShadow { enabled, .. }
|
||||
| LayerStyle::OuterGlow { enabled, .. }
|
||||
| LayerStyle::InnerGlow { enabled, .. }
|
||||
| LayerStyle::BevelEmboss { enabled, .. }
|
||||
| LayerStyle::Satin { enabled, .. }
|
||||
| LayerStyle::ColorOverlay { enabled, .. }
|
||||
| LayerStyle::GradientOverlay { enabled, .. }
|
||||
| LayerStyle::PatternOverlay { enabled, .. }
|
||||
| LayerStyle::Stroke { enabled, .. } => *enabled,
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the blend mode string for a style.
|
||||
fn get_blend_mode(discriminant: &str, styles: &[LayerStyle]) -> String {
|
||||
styles.iter().find(|s| style_matches(discriminant, s))
|
||||
.map(|s| match s {
|
||||
LayerStyle::DropShadow { blend_mode, .. }
|
||||
| LayerStyle::InnerShadow { blend_mode, .. }
|
||||
| LayerStyle::OuterGlow { blend_mode, .. }
|
||||
| LayerStyle::InnerGlow { blend_mode, .. }
|
||||
| LayerStyle::ColorOverlay { blend_mode, .. }
|
||||
| LayerStyle::GradientOverlay { blend_mode, .. }
|
||||
| LayerStyle::PatternOverlay { blend_mode, .. }
|
||||
| LayerStyle::Stroke { blend_mode, .. } => blend_mode.clone(),
|
||||
_ => "Normal".to_string(),
|
||||
})
|
||||
.unwrap_or_else(|| "Normal".to_string())
|
||||
}
|
||||
|
||||
/// Get the color for a style (first color parameter).
|
||||
fn get_style_color(discriminant: &str, styles: &[LayerStyle]) -> [u8; 4] {
|
||||
styles.iter().find(|s| style_matches(discriminant, s))
|
||||
.map(|s| match s {
|
||||
LayerStyle::DropShadow { color, .. }
|
||||
| LayerStyle::InnerShadow { color, .. }
|
||||
| LayerStyle::OuterGlow { color, .. }
|
||||
| LayerStyle::InnerGlow { color, .. }
|
||||
| LayerStyle::ColorOverlay { color, .. }
|
||||
| LayerStyle::Satin { color, .. }
|
||||
| LayerStyle::Stroke { color, .. } => *color,
|
||||
LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color,
|
||||
_ => [0, 0, 0, 255],
|
||||
})
|
||||
.unwrap_or([0, 0, 0, 255])
|
||||
}
|
||||
|
||||
/// Convert HSL (h: 0..360, s: 0..1, l: 0..1) to RGB u8.
|
||||
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [u8; 3] {
|
||||
let h = h / 360.0;
|
||||
let (r, g, b) = if s == 0.0 {
|
||||
(l, l, l)
|
||||
} else {
|
||||
for (i, style) in styles_list.iter().enumerate() {
|
||||
let (name, enabled) = match style {
|
||||
LayerStyle::DropShadow { enabled, .. } => ("Drop Shadow", *enabled),
|
||||
LayerStyle::InnerShadow { enabled, .. } => ("Inner Shadow", *enabled),
|
||||
LayerStyle::OuterGlow { enabled, .. } => ("Outer Glow", *enabled),
|
||||
LayerStyle::InnerGlow { enabled, .. } => ("Inner Glow", *enabled),
|
||||
LayerStyle::BevelEmboss { enabled, .. } => ("Bevel & Emboss", *enabled),
|
||||
LayerStyle::Satin { enabled, .. } => ("Satin", *enabled),
|
||||
LayerStyle::ColorOverlay { enabled, .. } => ("Color Overlay", *enabled),
|
||||
LayerStyle::GradientOverlay { enabled, .. } => ("Gradient Overlay", *enabled),
|
||||
LayerStyle::PatternOverlay { enabled, .. } => ("Pattern Overlay", *enabled),
|
||||
LayerStyle::Stroke { enabled, .. } => ("Stroke", *enabled),
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
(hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0))
|
||||
};
|
||||
[(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8]
|
||||
}
|
||||
|
||||
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 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 status = if enabled { "ON" } else { "OFF" };
|
||||
let toggle_btn = button(text(status).size(10))
|
||||
let badge_color = iced::Color::from_rgb(color_arr[0], color_arr[1], color_arr[2]);
|
||||
|
||||
let toggle_btn = button(text(status).size(9))
|
||||
.on_press(Message::LayerStyleToggle(i as u32))
|
||||
.padding([2, 6]);
|
||||
.padding([1, 4]);
|
||||
|
||||
let item = row![toggle_btn, text(name).size(11)].spacing(4).align_y(iced::Alignment::Center);
|
||||
let label_text = text(label).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(badge_color),
|
||||
});
|
||||
|
||||
let item_style = if enabled { styles::active_item_bg(colors) } else { styles::inactive_item_bg(colors) };
|
||||
let item_row = row![toggle_btn, label_text].spacing(3).align_y(iced::Alignment::Center);
|
||||
|
||||
let item_container = container(item)
|
||||
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([4, 6])
|
||||
.style(move |_theme| item_style.clone());
|
||||
.padding([3, 4])
|
||||
.style(move |_theme| item_bg.clone());
|
||||
|
||||
style_list = style_list.push(item_container);
|
||||
}
|
||||
let clickable = iced::widget::mouse_area(item_container)
|
||||
.on_press(Message::LayerStyleSelect(i));
|
||||
|
||||
sidebar_items = sidebar_items.push(clickable);
|
||||
}
|
||||
|
||||
let add_style_btn = button(text("+ Add Style").size(10))
|
||||
.on_press(Message::LayerStyleAdd)
|
||||
.padding([4, 8]);
|
||||
let sidebar = column![
|
||||
text("Styles").size(11).font(iced::Font::MONOSPACE),
|
||||
horizontal_rule(1),
|
||||
scrollable(sidebar_items).height(Length::Fill),
|
||||
]
|
||||
.spacing(2)
|
||||
.padding(4)
|
||||
.width(Length::Fixed(180.0));
|
||||
|
||||
let panel = column![
|
||||
text("Layer Styles").size(13).font(iced::Font::MONOSPACE),
|
||||
horizontal_rule(1),
|
||||
scrollable(style_list).height(Length::Fill),
|
||||
horizontal_rule(1),
|
||||
add_style_btn,
|
||||
// ── Right side: parameters ──
|
||||
let params = build_style_params(selected_style, styles_list, colors, show_color_picker, color_picker_idx, color_hsl);
|
||||
|
||||
// ── Buttons ──
|
||||
let ok_btn = button(text("OK").size(11))
|
||||
.on_press(Message::LayerStyleApply)
|
||||
.padding([6, 20]);
|
||||
let cancel_btn = button(text("Cancel").size(11))
|
||||
.on_press(Message::LayerStyleCancel)
|
||||
.padding([6, 20]);
|
||||
let buttons = row![ok_btn, cancel_btn].spacing(8).align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Dialog content ──
|
||||
let content = column![
|
||||
row![
|
||||
sidebar,
|
||||
column![params, horizontal_rule(1), buttons]
|
||||
.width(Length::Fill)
|
||||
.spacing(4)
|
||||
.padding(8),
|
||||
].spacing(4),
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(8);
|
||||
|
||||
container(panel)
|
||||
// ── Dialog frame with draggable title bar + X button ──
|
||||
let close_btn = button(text("\u{2715}").size(12))
|
||||
.on_press(Message::LayerStyleCancel)
|
||||
.padding([0, 6]);
|
||||
let title_bar = row![
|
||||
text("Layer Style").size(12).font(iced::Font::MONOSPACE),
|
||||
Space::with_width(Length::Fill),
|
||||
close_btn,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.padding([6, 8]);
|
||||
|
||||
// Make the title bar draggable — on_press starts the drag.
|
||||
// Global mouse move is handled by the subscription in app.rs.
|
||||
let drag_title = iced::widget::mouse_area(title_bar)
|
||||
.on_press(Message::LayerStyleDialogDragStart(0.0, 0.0));
|
||||
|
||||
let dialog = column![
|
||||
drag_title,
|
||||
horizontal_rule(1),
|
||||
content,
|
||||
]
|
||||
.spacing(0);
|
||||
|
||||
// ── Floating panel with offset from center + backdrop ──
|
||||
let ox = drag_offset.0;
|
||||
let oy = drag_offset.1;
|
||||
let backdrop = container(
|
||||
container(dialog)
|
||||
.width(Length::Fixed(580.0))
|
||||
.height(Length::Fixed(420.0))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.border_high).width(2).rounded(8),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6),
|
||||
offset: iced::Vector::new(ox, oy),
|
||||
blur_radius: 16.0,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.padding(iced::Padding {
|
||||
left: ox,
|
||||
right: -ox,
|
||||
top: oy,
|
||||
bottom: -oy,
|
||||
})
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| styles::panel_background(colors))
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
backdrop.into()
|
||||
}
|
||||
|
||||
/// Build parameter controls for a specific style.
|
||||
fn build_style_params<'a>(
|
||||
selected: usize,
|
||||
styles_list: &[LayerStyle],
|
||||
colors: ThemeColors,
|
||||
show_color_picker: bool,
|
||||
color_picker_idx: usize,
|
||||
color_hsl: (f32, f32, f32),
|
||||
) -> Element<'a, Message> {
|
||||
if selected >= STYLE_DEFS.len() {
|
||||
return text("Select a style from the list").size(11).into();
|
||||
}
|
||||
|
||||
let (disc, label, _) = STYLE_DEFS[selected];
|
||||
let style = styles_list.iter().find(|s| style_matches(disc, s));
|
||||
let blend_mode = get_blend_mode(disc, styles_list);
|
||||
let style_color = get_style_color(disc, styles_list);
|
||||
|
||||
// Leak the blend mode string so pick_list can borrow it with 'static lifetime.
|
||||
// This is reclaimed on next frame when the dialog rebuilds.
|
||||
let blend_mode_static: &'static str = Box::leak(blend_mode.into_boxed_str());
|
||||
|
||||
let mut params = column![].spacing(4);
|
||||
params = params.push(text(label).size(12).font(iced::Font::MONOSPACE));
|
||||
params = params.push(horizontal_rule(1));
|
||||
|
||||
// Blend Mode dropdown
|
||||
let blend_list = pick_list(BLEND_MODES, Some(blend_mode_static), move |mode: &str| {
|
||||
Message::LayerStyleUpdateBlendMode(selected, mode.to_string())
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.text_size(10);
|
||||
|
||||
params = params.push(row![text("Blend Mode:").size(10), blend_list].spacing(4).align_y(iced::Alignment::Center));
|
||||
|
||||
// Opacity slider (common to most styles)
|
||||
let opacity = match style {
|
||||
Some(LayerStyle::DropShadow { opacity, .. })
|
||||
| Some(LayerStyle::InnerShadow { opacity, .. })
|
||||
| Some(LayerStyle::OuterGlow { opacity, .. })
|
||||
| Some(LayerStyle::InnerGlow { opacity, .. })
|
||||
| Some(LayerStyle::Satin { opacity, .. })
|
||||
| Some(LayerStyle::ColorOverlay { opacity, .. })
|
||||
| Some(LayerStyle::GradientOverlay { opacity, .. })
|
||||
| Some(LayerStyle::PatternOverlay { opacity, .. })
|
||||
| Some(LayerStyle::Stroke { opacity, .. }) => *opacity,
|
||||
Some(LayerStyle::BevelEmboss { highlight_opacity, .. }) => *highlight_opacity,
|
||||
_ => 1.0,
|
||||
};
|
||||
params = params.push(slider_row("Opacity", opacity, 0.0..=1.0, move |v| Message::LayerStyleUpdateParam(selected, "opacity".to_string(), v), colors));
|
||||
|
||||
// Color swatch — clickable to open color picker
|
||||
let sc = style_color;
|
||||
let swatch_bg = container(text(""))
|
||||
.width(32)
|
||||
.height(24)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
sc[0] as f32 / 255.0, sc[1] as f32 / 255.0, sc[2] as f32 / 255.0, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.5, 0.5, 0.5)).width(1).rounded(2),
|
||||
..Default::default()
|
||||
});
|
||||
let swatch_clickable = iced::widget::mouse_area(swatch_bg)
|
||||
.on_press(Message::ShowStyleColorPicker(selected))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
let color_label = row![text("Color:").size(10), swatch_clickable].spacing(4).align_y(iced::Alignment::Center);
|
||||
params = params.push(color_label);
|
||||
|
||||
// Style-specific sliders
|
||||
match disc {
|
||||
"DropShadow" | "InnerShadow" => {
|
||||
let (angle, distance, spread, size) = match style {
|
||||
Some(LayerStyle::DropShadow { angle, distance, spread, size, .. }) |
|
||||
Some(LayerStyle::InnerShadow { angle, distance, spread, size, .. }) =>
|
||||
(*angle, *distance, *spread, *size),
|
||||
_ => (120.0, 5.0, 0.0, 5.0),
|
||||
};
|
||||
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
|
||||
params = params.push(slider_row("Distance", distance, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v), colors));
|
||||
params = params.push(slider_row("Spread", spread, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v), colors));
|
||||
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
}
|
||||
"OuterGlow" | "InnerGlow" => {
|
||||
let (spread, size) = match style {
|
||||
Some(LayerStyle::OuterGlow { spread, size, .. }) |
|
||||
Some(LayerStyle::InnerGlow { spread, size, .. }) =>
|
||||
(*spread, *size),
|
||||
_ => (0.0, 5.0),
|
||||
};
|
||||
params = params.push(slider_row("Spread", spread, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v), colors));
|
||||
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
}
|
||||
"BevelEmboss" => {
|
||||
let (depth, size, angle, altitude, soften) = match style {
|
||||
Some(LayerStyle::BevelEmboss { depth, size, angle, altitude, soften, .. }) =>
|
||||
(*depth, *size, *angle, *altitude, *soften),
|
||||
_ => (1.0, 5.0, 120.0, 30.0, 0.0),
|
||||
};
|
||||
params = params.push(slider_row("Depth", depth, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "depth".to_string(), v), colors));
|
||||
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
|
||||
params = params.push(slider_row("Altitude", altitude, 0.0..=90.0, move |v| Message::LayerStyleUpdateParam(selected, "altitude".to_string(), v), colors));
|
||||
params = params.push(slider_row("Soften", soften, 0.0..=16.0, move |v| Message::LayerStyleUpdateParam(selected, "soften".to_string(), v), colors));
|
||||
}
|
||||
"Satin" => {
|
||||
let (angle, distance, size) = match style {
|
||||
Some(LayerStyle::Satin { angle, distance, size, .. }) =>
|
||||
(*angle, *distance, *size),
|
||||
_ => (120.0, 11.0, 14.0),
|
||||
};
|
||||
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
|
||||
params = params.push(slider_row("Distance", distance, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v), colors));
|
||||
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
}
|
||||
"ColorOverlay" => {
|
||||
// Only opacity — color is via the swatch above
|
||||
}
|
||||
"GradientOverlay" => {
|
||||
let (angle, scale) = match style {
|
||||
Some(LayerStyle::GradientOverlay { angle, scale, .. }) =>
|
||||
(*angle, *scale),
|
||||
_ => (90.0, 1.0),
|
||||
};
|
||||
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
|
||||
params = params.push(slider_row("Scale", scale, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v), colors));
|
||||
}
|
||||
"PatternOverlay" => {
|
||||
let scale = match style {
|
||||
Some(LayerStyle::PatternOverlay { scale, .. }) => *scale,
|
||||
_ => 1.0,
|
||||
};
|
||||
params = params.push(slider_row("Scale", scale, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v), colors));
|
||||
}
|
||||
"Stroke" => {
|
||||
let size = match style {
|
||||
Some(LayerStyle::Stroke { size, .. }) => *size,
|
||||
_ => 3.0,
|
||||
};
|
||||
params = params.push(slider_row("Size", size, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Show color picker popup if active
|
||||
if show_color_picker && color_picker_idx == selected {
|
||||
params = params.push(build_color_picker_popup(color_hsl, selected, colors));
|
||||
}
|
||||
|
||||
scrollable(params).height(Length::Fill).into()
|
||||
}
|
||||
|
||||
/// Build a compact HSL color picker popup for a layer style.
|
||||
fn build_color_picker_popup<'a>(
|
||||
color_hsl: (f32, f32, f32),
|
||||
style_idx: usize,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let (h, s, l) = color_hsl;
|
||||
let rgb = hsl_to_rgb(h, s, l);
|
||||
|
||||
// Hue slider
|
||||
let h_slider = slider(0.0..=360.0, h, move |v: f32| {
|
||||
let rgb = hsl_to_rgb(v, s, l);
|
||||
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
|
||||
}).step(1.0);
|
||||
|
||||
// Saturation slider
|
||||
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| {
|
||||
let rgb = hsl_to_rgb(h, v / 100.0, l);
|
||||
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
|
||||
}).step(1.0);
|
||||
|
||||
// Lightness slider
|
||||
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| {
|
||||
let rgb = hsl_to_rgb(h, s, v / 100.0);
|
||||
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
|
||||
}).step(1.0);
|
||||
|
||||
// Color preview swatch
|
||||
let preview = container(text(""))
|
||||
.width(Length::Fill)
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
rgb[0] as f32 / 255.0, rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(2).color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Hex display
|
||||
let hex = format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]);
|
||||
|
||||
let popup = column![
|
||||
text("Color Picker").size(10).font(iced::Font::MONOSPACE),
|
||||
preview,
|
||||
row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(3),
|
||||
row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(3),
|
||||
row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(3),
|
||||
row![text(hex).size(9).font(iced::Font::MONOSPACE),
|
||||
Space::with_width(Length::Fill),
|
||||
button(text("Close").size(9)).on_press(Message::HideStyleColorPicker).padding([2, 8])].spacing(4),
|
||||
]
|
||||
.spacing(3)
|
||||
.padding(6);
|
||||
|
||||
container(popup)
|
||||
.width(Length::Fixed(260.0))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.border_high).width(1).rounded(4),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
||||
offset: iced::Vector::new(0.0, 2.0),
|
||||
blur_radius: 8.0,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build a compact slider row.
|
||||
fn slider_row<'a>(
|
||||
label: &'a str,
|
||||
value: f32,
|
||||
range: std::ops::RangeInclusive<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,
|
||||
//! opacity adjustment, blend mode selection, and layer operations.
|
||||
//! Uses ThemeColors for consistent styling.
|
||||
//! Layout (top to bottom):
|
||||
//! 1. Active layer controls — blend mode pick_list, opacity slider, lock toggle
|
||||
//! 2. Scrollable layer list — each row shows indent, collapse arrow, visibility, thumbnail,
|
||||
//! name, FX icon, and type badge. Layers with effects show expandable effect sub-items.
|
||||
//! 3. Bottom toolbar — add layer, add group, flatten, delete, move up/down
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::panels::thumbnails;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::{BlendMode, LayerInfo};
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text};
|
||||
use hcie_engine_api::{BlendMode, LayerInfo, LayerType};
|
||||
use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Wrapper for BlendMode to provide Display for pick_list.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct BlendModeItem(BlendMode);
|
||||
|
||||
impl std::fmt::Display for BlendModeItem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0.label())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<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.
|
||||
pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColors) -> Element<'a, Message> {
|
||||
let mut layer_list = column![].spacing(1);
|
||||
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);
|
||||
|
||||
// Render layers in reverse order (top layer first in UI)
|
||||
for layer in layers.iter().rev() {
|
||||
let is_active = layer.id == active_layer_id;
|
||||
// ── 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 name_text = text(&layer.name)
|
||||
.size(11)
|
||||
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 name_text = if is_active {
|
||||
name_text.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.accent),
|
||||
})
|
||||
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);
|
||||
|
||||
for entry in &entries {
|
||||
let info = entry.info;
|
||||
let is_active = info.id == active_layer_id;
|
||||
let depth = entry.depth;
|
||||
|
||||
// Indent spacer
|
||||
let indent: Element<'a, Message> = Space::with_width(
|
||||
Length::Fixed((depth as f32) * 14.0)
|
||||
).into();
|
||||
|
||||
// Collapse arrow (groups only)
|
||||
let collapse: Element<'a, Message> = if entry.is_group {
|
||||
let arrow = if info.collapsed { "\u{25B6}" } else { "\u{25BC}" };
|
||||
button(text(arrow).size(8))
|
||||
.on_press(Message::LayerToggleCollapse(info.id))
|
||||
.padding([0, 1])
|
||||
.style(move |_theme, _status| flat_btn_style(colors))
|
||||
.into()
|
||||
} else {
|
||||
name_text
|
||||
Space::with_width(Length::Fixed(12.0)).into()
|
||||
};
|
||||
|
||||
// Visibility toggle
|
||||
let vis_icon = if layer.visible { "👁" } else { " " };
|
||||
let vis_btn = button(text(vis_icon).size(10))
|
||||
.on_press(Message::LayerToggleVisibility(layer.id))
|
||||
.padding([2, 4]);
|
||||
let vis_char = if info.visible { "\u{25CF}" } else { "\u{25CB}" };
|
||||
let vis_color = if info.visible { colors.text_primary } else { colors.text_secondary };
|
||||
let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(vis_color),
|
||||
}))
|
||||
.on_press(Message::LayerToggleVisibility(info.id))
|
||||
.padding([0, 2])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
// Lock toggle
|
||||
let lock_icon = if layer.locked { "🔒" } else { " " };
|
||||
let lock_btn = button(text(lock_icon).size(10))
|
||||
.on_press(Message::LayerToggleLock(layer.id))
|
||||
.padding([2, 4]);
|
||||
// Thumbnail
|
||||
let thumb_elem = layer_thumbnail(entry, engine, colors);
|
||||
|
||||
// Layer name
|
||||
let c = if is_active { colors.accent } else { colors.text_primary };
|
||||
let name_text = text(&info.name)
|
||||
.size(10)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(c) });
|
||||
|
||||
// "fx" icon for layers with effects (Photoshop-style)
|
||||
let fx_icon: Element<'a, Message> = if info.has_effects {
|
||||
button(
|
||||
text("fx").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.accent),
|
||||
})
|
||||
)
|
||||
.on_press(Message::LayerStyleToggle(0)) // Opens styles panel
|
||||
.padding([0, 2])
|
||||
.style(move |_theme, _status| flat_btn_style(colors))
|
||||
.into()
|
||||
} else {
|
||||
Space::with_width(Length::Fixed(1.0)).into()
|
||||
};
|
||||
|
||||
// Type badge
|
||||
let type_badge: Element<'a, Message> = match info.layer_type {
|
||||
LayerType::Vector => text("V").size(8)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.4, 0.8, 0.4)),
|
||||
}).into(),
|
||||
LayerType::Text => text("T").size(8)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.4, 0.6, 1.0)),
|
||||
}).into(),
|
||||
LayerType::Group => text("G").size(8)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.85, 0.7, 0.3)),
|
||||
}).into(),
|
||||
LayerType::Mask => text("M").size(8)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.8, 0.4, 0.8)),
|
||||
}).into(),
|
||||
_ => text("").size(8).into(),
|
||||
};
|
||||
|
||||
// Main layer row
|
||||
let name_row = row![
|
||||
collapse,
|
||||
vis_btn,
|
||||
lock_btn,
|
||||
thumb_elem,
|
||||
name_text,
|
||||
fx_icon,
|
||||
type_badge,
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center);
|
||||
@@ -54,94 +301,116 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo
|
||||
let layer_item = if is_active {
|
||||
container(name_row)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 6])
|
||||
.style(move |_theme| styles::active_item_bg(colors))
|
||||
.padding([2, 3])
|
||||
.style(move |_theme| {
|
||||
let accent_bg = iced::Color::from_rgba(
|
||||
colors.accent.r, colors.accent.g, colors.accent.b, 0.18,
|
||||
);
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(accent_bg)),
|
||||
border: iced::Border::default().color(
|
||||
iced::Color::from_rgba(colors.accent.r, colors.accent.g, colors.accent.b, 0.4)
|
||||
).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
container(name_row)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 6])
|
||||
.padding([2, 3])
|
||||
.style(move |_theme| styles::inactive_item_bg(colors))
|
||||
};
|
||||
|
||||
// Click to select layer
|
||||
let clickable = iced::widget::mouse_area(layer_item)
|
||||
.on_press(Message::LayerSelect(layer.id));
|
||||
.on_press(Message::LayerSelect(info.id));
|
||||
|
||||
layer_list = layer_list.push(clickable);
|
||||
}
|
||||
let row_with_indent = row![indent, clickable].align_y(iced::Alignment::Center);
|
||||
layer_list = layer_list.push(row_with_indent);
|
||||
|
||||
// Opacity slider for active layer
|
||||
let active_opacity = layers.iter()
|
||||
.find(|l| l.id == active_layer_id)
|
||||
.map(|l| l.opacity)
|
||||
.unwrap_or(1.0);
|
||||
// ── 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;
|
||||
|
||||
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)
|
||||
.padding([4, 8]);
|
||||
|
||||
let del_btn = button(text("−").size(12))
|
||||
.on_press(Message::LayerDelete(active_layer_id))
|
||||
.padding([4, 8]);
|
||||
|
||||
let merge_btn = button(text("Merge").size(10))
|
||||
.on_press(Message::LayerMergeDown)
|
||||
.padding([4, 8]);
|
||||
|
||||
let flatten_btn = button(text("Flatten").size(10))
|
||||
.on_press(Message::LayerFlatten)
|
||||
.padding([4, 8]);
|
||||
|
||||
let move_up_btn = button(text("▲").size(10))
|
||||
.on_press(Message::LayerMoveUp(active_layer_id))
|
||||
.padding([4, 4]);
|
||||
|
||||
let move_down_btn = button(text("▼").size(10))
|
||||
.on_press(Message::LayerMoveDown(active_layer_id))
|
||||
.padding([4, 4]);
|
||||
|
||||
let buttons_row = row![
|
||||
add_btn, del_btn, merge_btn, flatten_btn,
|
||||
iced::widget::horizontal_rule(1),
|
||||
move_up_btn, move_down_btn,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom toolbar ─────────────────────────────────────
|
||||
let add_btn = button(text("+Lay").size(10))
|
||||
.on_press(Message::LayerAdd)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let group_btn = button(text("+Grp").size(10))
|
||||
.on_press(Message::LayerAddGroup)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let flatten_btn = button(text("Flatten").size(10))
|
||||
.on_press(Message::LayerFlatten)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let del_btn = button(text("\u{2716}").size(10))
|
||||
.on_press(Message::LayerDelete(active_layer_id))
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let move_up_btn = button(text("\u{25B2}").size(10))
|
||||
.on_press(Message::LayerMoveUp(active_layer_id))
|
||||
.padding([3, 4])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let move_down_btn = button(text("\u{25BC}").size(10))
|
||||
.on_press(Message::LayerMoveDown(active_layer_id))
|
||||
.padding([3, 4])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
let fx_btn = button(text("fx").size(10))
|
||||
.on_press(Message::OpenLayerStyleDialog)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let toolbar = row![
|
||||
add_btn, group_btn, flatten_btn,
|
||||
iced::widget::horizontal_rule(1),
|
||||
fx_btn,
|
||||
iced::widget::horizontal_rule(1),
|
||||
move_up_btn, move_down_btn,
|
||||
iced::widget::horizontal_rule(1),
|
||||
del_btn,
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Panel layout ───────────────────────────────────────
|
||||
let panel = column![
|
||||
text("Layers").size(13).font(iced::Font::MONOSPACE),
|
||||
text("Layers").size(11).font(iced::Font::MONOSPACE),
|
||||
horizontal_rule(1),
|
||||
controls,
|
||||
horizontal_rule(1),
|
||||
scrollable(layer_list).height(Length::Fill),
|
||||
horizontal_rule(1),
|
||||
opacity_row,
|
||||
opacity_slider,
|
||||
row![text("Blend:").size(10), blend_label].spacing(4),
|
||||
horizontal_rule(1),
|
||||
buttons_row,
|
||||
toolbar,
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(8);
|
||||
.spacing(2)
|
||||
.padding(4);
|
||||
|
||||
container(panel)
|
||||
.width(Length::Fill)
|
||||
@@ -149,3 +418,33 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo
|
||||
.style(move |_theme| styles::panel_background(colors))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Map FX badge code to full effect name (for expandable sub-items).
|
||||
fn effect_full_name(code: &str) -> &'static str {
|
||||
match code {
|
||||
"DS" => "Drop Shadow",
|
||||
"IS" => "Inner Shadow",
|
||||
"OG" => "Outer Glow",
|
||||
"IG" => "Inner Glow",
|
||||
"BE" => "Bevel & Emboss",
|
||||
"St" => "Satin",
|
||||
"CO" => "Color Overlay",
|
||||
"GO" => "Gradient Overlay",
|
||||
"PO" => "Pattern Overlay",
|
||||
"Sr" => "Stroke",
|
||||
_ => "Effect",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map FX badge code to a display color (matches egui palette).
|
||||
fn fx_color(code: &str) -> iced::Color {
|
||||
match code {
|
||||
"DS" => iced::Color::from_rgb(0.86, 0.39, 0.39),
|
||||
"IS" => iced::Color::from_rgb(0.71, 0.47, 0.47),
|
||||
"OG" => iced::Color::from_rgb(0.86, 0.86, 0.39),
|
||||
"IG" => iced::Color::from_rgb(0.71, 0.71, 0.39),
|
||||
"BE" => iced::Color::from_rgb(0.39, 0.71, 0.86),
|
||||
"St" => iced::Color::from_rgb(0.71, 0.55, 0.86),
|
||||
_ => iced::Color::from_rgb(0.5, 0.5, 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,63 +9,71 @@ use iced::widget::{column, container, horizontal_rule, mouse_area, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Menu item definition.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
struct MenuItem {
|
||||
label: &'static str,
|
||||
shortcut: &'static str,
|
||||
label: String,
|
||||
shortcut: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
fn new(label: &'static str) -> Self {
|
||||
Self { label, shortcut: "", enabled: true }
|
||||
fn new(label: &str) -> Self {
|
||||
Self { label: label.to_string(), shortcut: String::new(), enabled: true }
|
||||
}
|
||||
|
||||
fn with_shortcut(label: &'static str, shortcut: &'static str) -> Self {
|
||||
Self { label, shortcut, enabled: true }
|
||||
fn with_shortcut(label: &str, shortcut: &str) -> Self {
|
||||
Self { label: label.to_string(), shortcut: shortcut.to_string(), enabled: true }
|
||||
}
|
||||
|
||||
fn separator() -> Self {
|
||||
Self { label: "─", shortcut: "", enabled: false }
|
||||
Self { label: "─".to_string(), shortcut: String::new(), enabled: false }
|
||||
}
|
||||
|
||||
fn disabled(label: &'static str) -> Self {
|
||||
Self { label, shortcut: "", enabled: false }
|
||||
fn disabled(label: &str) -> Self {
|
||||
Self { label: label.to_string(), shortcut: String::new(), enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// All menu definitions.
|
||||
#[derive(Clone)]
|
||||
struct MenuDef {
|
||||
#[allow(dead_code)]
|
||||
label: &'static str,
|
||||
label: String,
|
||||
items: Vec<MenuItem>,
|
||||
}
|
||||
|
||||
fn all_menus() -> Vec<MenuDef> {
|
||||
vec![
|
||||
// File (0)
|
||||
MenuDef {
|
||||
label: "File",
|
||||
items: vec![
|
||||
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"),
|
||||
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)
|
||||
];
|
||||
|
||||
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![
|
||||
MenuDef { label: "File".to_string(), items: file_items },
|
||||
MenuDef {
|
||||
label: "Edit",
|
||||
label: "Edit".to_string(),
|
||||
items: vec![
|
||||
MenuItem::with_shortcut("Undo", "Ctrl+Z"),
|
||||
MenuItem::with_shortcut("Redo", "Ctrl+Y"),
|
||||
@@ -79,60 +87,39 @@ fn all_menus() -> Vec<MenuDef> {
|
||||
MenuItem::new("Invert Selection"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Fill..."),
|
||||
MenuItem::new("Stroke..."),
|
||||
],
|
||||
},
|
||||
// Tools (2)
|
||||
MenuDef {
|
||||
label: "Tools",
|
||||
label: "Tools".to_string(),
|
||||
items: vec![
|
||||
MenuItem::new("Brush"),
|
||||
MenuItem::new("Eraser"),
|
||||
MenuItem::new("Move"),
|
||||
MenuItem::new("Vector Select"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Rectangle Select"),
|
||||
MenuItem::new("Marquee"),
|
||||
MenuItem::new("Lasso"),
|
||||
MenuItem::new("Polygon Select"),
|
||||
MenuItem::new("Magic Wand"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Crop"),
|
||||
MenuItem::new("Gradient"),
|
||||
MenuItem::new("Paint Bucket"),
|
||||
MenuItem::new("Eyedropper"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Brush"),
|
||||
MenuItem::new("Pen"),
|
||||
MenuItem::new("Spray"),
|
||||
MenuItem::new("Eraser"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Flood Fill"),
|
||||
MenuItem::new("Gradient"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Text"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Vector Line"),
|
||||
MenuItem::new("Vector Rectangle"),
|
||||
MenuItem::new("Vector Circle"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Reset Tool"),
|
||||
MenuItem::new("Shape"),
|
||||
],
|
||||
},
|
||||
// Image (3)
|
||||
MenuDef {
|
||||
label: "Image",
|
||||
label: "Image".to_string(),
|
||||
items: vec![
|
||||
MenuItem::new("Canvas Size..."),
|
||||
MenuItem::new("Image Size..."),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Rotate 90° CW"),
|
||||
MenuItem::new("Rotate 90° CCW"),
|
||||
MenuItem::new("Rotate 180°"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Flip Horizontal"),
|
||||
MenuItem::new("Flip Vertical"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Crop to Selection"),
|
||||
MenuItem::new("Crop"),
|
||||
MenuItem::new("Flatten"),
|
||||
],
|
||||
},
|
||||
// Layer (4)
|
||||
MenuDef {
|
||||
label: "Layer",
|
||||
label: "Layer".to_string(),
|
||||
items: vec![
|
||||
MenuItem::new("New Layer"),
|
||||
MenuItem::new("Duplicate Layer"),
|
||||
@@ -141,109 +128,135 @@ fn all_menus() -> Vec<MenuDef> {
|
||||
MenuItem::new("Merge Down"),
|
||||
MenuItem::new("Flatten Image"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Clear Layer"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Layer Styles..."),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Align Left"),
|
||||
MenuItem::new("Align Center"),
|
||||
MenuItem::new("Align Right"),
|
||||
],
|
||||
},
|
||||
// Filter (5)
|
||||
MenuDef {
|
||||
label: "Filter",
|
||||
label: "Filter".to_string(),
|
||||
items: vec![
|
||||
MenuItem::disabled("─ Blur"),
|
||||
MenuItem::new(" Box Blur"),
|
||||
MenuItem::new(" Gaussian Blur"),
|
||||
MenuItem::new(" Motion Blur"),
|
||||
MenuItem::disabled("─ Sharpen"),
|
||||
MenuItem::new(" Unsharp Mask"),
|
||||
MenuItem::disabled("─ Pixelate"),
|
||||
MenuItem::new(" Mosaic"),
|
||||
MenuItem::new(" Crystallize"),
|
||||
MenuItem::disabled("─ Distort"),
|
||||
MenuItem::new(" Pinch"),
|
||||
MenuItem::new(" Twirl"),
|
||||
MenuItem::disabled("─ Stylize"),
|
||||
MenuItem::new(" Oil Paint"),
|
||||
MenuItem::disabled("─ Color & Light"),
|
||||
MenuItem::new(" Levels"),
|
||||
MenuItem::new(" Vibrance"),
|
||||
MenuItem::new(" Exposure"),
|
||||
MenuItem::new(" Posterize"),
|
||||
MenuItem::new(" Threshold"),
|
||||
MenuItem::disabled("─ Restore"),
|
||||
MenuItem::new(" Dehaze"),
|
||||
],
|
||||
},
|
||||
// Select (6)
|
||||
MenuDef {
|
||||
label: "Select",
|
||||
items: vec![
|
||||
MenuItem::with_shortcut("All", "Ctrl+A"),
|
||||
MenuItem::with_shortcut("Deselect", "Ctrl+D"),
|
||||
MenuItem::new("Invert Selection"),
|
||||
MenuItem::new("Blur"),
|
||||
MenuItem::new("Sharpen"),
|
||||
MenuItem::new("Noise"),
|
||||
MenuItem::new("Distort"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Grow..."),
|
||||
MenuItem::new("Shrink..."),
|
||||
MenuItem::new("Feather..."),
|
||||
MenuItem::new("Apply Last Filter"),
|
||||
],
|
||||
},
|
||||
// View (7)
|
||||
MenuDef {
|
||||
label: "View",
|
||||
label: "Select".to_string(),
|
||||
items: vec![
|
||||
MenuItem::new("All"),
|
||||
MenuItem::new("Deselect"),
|
||||
MenuItem::new("Inverse"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Color Range..."),
|
||||
],
|
||||
},
|
||||
MenuDef {
|
||||
label: "View".to_string(),
|
||||
items: vec![
|
||||
MenuItem::new("Zoom In"),
|
||||
MenuItem::new("Zoom Out"),
|
||||
MenuItem::new("Zoom Reset"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Fit to Window"),
|
||||
MenuItem::new("100%"),
|
||||
MenuItem::new("200%"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Debug Mode"),
|
||||
MenuItem::new("Fit on Screen"),
|
||||
MenuItem::new("Actual Pixels"),
|
||||
],
|
||||
},
|
||||
// Help (8)
|
||||
MenuDef {
|
||||
label: "Help",
|
||||
label: "Help".to_string(),
|
||||
items: vec![
|
||||
MenuItem::new("About HCIE"),
|
||||
MenuItem::new("Documentation"),
|
||||
MenuItem::new("License"),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Build a dropdown menu overlay for the given menu index.
|
||||
///
|
||||
/// Returns `None` if `active_menu` is `None`. The returned element is meant
|
||||
/// to be placed in a `Stack` overlay on top of all other content.
|
||||
pub fn dropdown_overlay<'a>(active_menu: Option<usize>) -> Option<Element<'a, Message>> {
|
||||
pub fn dropdown_overlay(active_menu: Option<usize>, recent_files: &[crate::app::RecentFileEntry]) -> Option<Element<'static, Message>> {
|
||||
let menu_idx = active_menu?;
|
||||
let menus = all_menus();
|
||||
let menu = menus.get(menu_idx)?;
|
||||
let menus = all_menus(recent_files);
|
||||
let menu_items = menus.get(menu_idx)?.items.clone();
|
||||
|
||||
let mut items = column![].spacing(0);
|
||||
let mut items: Vec<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 == "─" {
|
||||
// Separator
|
||||
items = items.push(
|
||||
items.push(
|
||||
container(horizontal_rule(1))
|
||||
.width(Length::Fill)
|
||||
.padding([2, 8])
|
||||
.into()
|
||||
);
|
||||
} else if item.label == "Open Recent" {
|
||||
let lbl = item.label.clone();
|
||||
let disabled_item = container(
|
||||
text(lbl).size(10)
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 16])
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
..Default::default()
|
||||
});
|
||||
items.push(disabled_item.into());
|
||||
} else if item_idx >= 4 && item_idx < 4 + recent_files.len().min(10) {
|
||||
let file_idx = item_idx - 4;
|
||||
let lbl = item.label.clone();
|
||||
let btn = iced::widget::button(
|
||||
container(text(lbl).size(11)).width(Length::Fill).padding([4, 16])
|
||||
)
|
||||
.on_press(Message::OpenRecentFile(file_idx))
|
||||
.padding(0)
|
||||
.style(|_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.227, 0.227, 0.227))),
|
||||
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
items.push(btn.into());
|
||||
} else if item.label == "Clear Recent" {
|
||||
let lbl = item.label.clone();
|
||||
let btn = iced::widget::button(
|
||||
container(text(lbl).size(11)).width(Length::Fill).padding([4, 16])
|
||||
)
|
||||
.on_press(Message::ClearRecentFiles)
|
||||
.padding(0)
|
||||
.style(|_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.227, 0.227, 0.227))),
|
||||
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
items.push(btn.into());
|
||||
} else {
|
||||
let label_row: Element<'_, Message> = if item.shortcut.is_empty() {
|
||||
row![text(item.label).size(11)].into()
|
||||
let lbl = item.label.clone();
|
||||
let sht = item.shortcut.clone();
|
||||
let label_row: Element<'static, Message> = if sht.is_empty() {
|
||||
row![text(lbl).size(11)].into()
|
||||
} else {
|
||||
row![
|
||||
text(item.label).size(11).width(Length::Fill),
|
||||
text(item.shortcut).size(10),
|
||||
text(lbl).size(11).width(Length::Fill),
|
||||
text(sht).size(10),
|
||||
]
|
||||
.spacing(16)
|
||||
.into()
|
||||
@@ -275,7 +288,7 @@ pub fn dropdown_overlay<'a>(active_menu: Option<usize>) -> Option<Element<'a, Me
|
||||
},
|
||||
}
|
||||
});
|
||||
items = items.push(btn);
|
||||
items.push(btn.into());
|
||||
} else {
|
||||
let disabled_item = container(label_row)
|
||||
.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))),
|
||||
..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)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
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()
|
||||
});
|
||||
|
||||
// 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(
|
||||
container(dropdown)
|
||||
.padding(iced::Padding {
|
||||
|
||||
@@ -15,4 +15,5 @@ pub mod script_panel;
|
||||
pub mod status_bar;
|
||||
pub mod styles;
|
||||
pub mod text_editor;
|
||||
pub mod thumbnails;
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/mnt/extra/00_PROJECTS/hcie-rust-v3.05/_images/_test_images/example3/Example3-mini.psd
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user