This commit is contained in:
2026-07-13 06:40:14 +03:00
parent a70bedd6bb
commit 356fac571d
18 changed files with 2360 additions and 471 deletions
+712 -86
View File
@@ -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.full_upload.replace(true);
doc.dirty_region.replace(None);
doc.render_generation = doc.render_generation.wrapping_add(1);
let t2 = std::time::Instant::now();
let _total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0;
let _copy_ms = t2.duration_since(t1).as_secs_f64() * 1000.0;
// Performance log for engine rendering duration + CPU memory copy times.
// useful to check if dirty-region composite calculations start slowing down.
// log::info!(
// "[perf] render_composite_region: {:.1}ms, copy {:.1}ms, total {:.1}ms | region {}×{}",
// engine_ms, copy_ms, total_ms, rw, rh
// );
} else {
// Performance log triggered if the render pass returns a null buffer pointer.
// log::info!("[perf] render_composite_region: {:.1}ms, no copy (null ptr)", engine_ms);
}
doc.engine.clear_dirty_flags();
@@ -634,6 +739,9 @@ impl HcieIcedApp {
let color = self.fg_color;
self.active_document_mut().engine.set_color(color);
}
Message::ColorTabChanged(tab) => {
self.color_tab = tab;
}
Message::CanvasPointerPressed { x, y } => {
// Coordinates are already in canvas-space from the canvas widget
@@ -899,6 +1007,27 @@ impl HcieIcedApp {
}
}
Message::LayerSetBlendMode(id, mode) => {
self.documents[self.active_doc].engine.set_layer_blend_mode(id, mode);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerSetFillOpacity(_id, _opacity) => {
// Fill opacity is not yet exposed via engine API; placeholder for future use.
}
Message::LayerAddGroup => {
let count = self.documents[self.active_doc].engine.get_layer_count();
self.documents[self.active_doc].engine.add_group(&format!("Group {}", count + 1));
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleCollapse(id) => {
self.documents[self.active_doc].engine.toggle_group_collapsed(id);
}
// ── History ───────────────────────────────────
Message::HistoryJumpTo(idx) => {
self.documents[self.active_doc].engine.jump_to_history(idx);
@@ -924,13 +1053,370 @@ impl HcieIcedApp {
}
// ── Layer styles ──────────────────────────────
Message::LayerStyleToggle(_index) => {
// TODO: toggle layer style enabled state
Message::LayerStyleToggle(index) => {
// Toggle layer style enabled state.
// BUG FIX: When style doesn't exist, first click creates it ENABLED (not disabled).
// Previous logic used map_or(false, ...) which created disabled styles on first click.
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if (index as usize) < disc_names.len() {
let disc = disc_names[index as usize];
// Find existing style
let existing = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
// Determine new enabled state:
// - Style doesn't exist → first click enables it (true)
// - Style exists → toggle current state
let new_enabled = match existing {
None => true, // First click: create ENABLED
Some(s) => {
let was_enabled = match s {
LayerStyle::DropShadow { enabled, .. }
| LayerStyle::InnerShadow { enabled, .. }
| LayerStyle::OuterGlow { enabled, .. }
| LayerStyle::InnerGlow { enabled, .. }
| LayerStyle::BevelEmboss { enabled, .. }
| LayerStyle::Satin { enabled, .. }
| LayerStyle::ColorOverlay { enabled, .. }
| LayerStyle::GradientOverlay { enabled, .. }
| LayerStyle::PatternOverlay { enabled, .. }
| LayerStyle::Stroke { enabled, .. } => *enabled,
};
!was_enabled // Toggle
}
};
let new_style = match disc {
"DropShadow" => LayerStyle::DropShadow {
enabled: new_enabled,
opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0,
color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(),
},
"InnerShadow" => LayerStyle::InnerShadow {
enabled: new_enabled,
opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0,
color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(),
},
"OuterGlow" => LayerStyle::OuterGlow {
enabled: new_enabled,
opacity: 0.75, spread: 0.0, size: 5.0,
color: [255, 255, 190, 255], blend_mode: "Screen".to_string(),
},
"InnerGlow" => LayerStyle::InnerGlow {
enabled: new_enabled,
opacity: 0.75, spread: 0.0, size: 5.0,
color: [255, 255, 190, 255], blend_mode: "Screen".to_string(),
},
"BevelEmboss" => LayerStyle::BevelEmboss {
enabled: new_enabled,
depth: 1.0, size: 5.0, angle: 120.0, altitude: 30.0,
highlight_opacity: 0.75, shadow_opacity: 0.75,
direction: "Up".to_string(), style: "InnerBevel".to_string(),
technique: "Smooth".to_string(), soften: 0.0,
highlight_blend_mode: "Screen".to_string(),
highlight_color: [255, 255, 255, 255],
shadow_blend_mode: "Multiply".to_string(),
shadow_color: [0, 0, 0, 255],
},
"Satin" => LayerStyle::Satin {
enabled: new_enabled,
opacity: 0.5, angle: 120.0, distance: 11.0, size: 14.0,
color: [0, 0, 0, 255], invert: true,
},
"ColorOverlay" => LayerStyle::ColorOverlay {
enabled: new_enabled,
opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(),
},
"GradientOverlay" => LayerStyle::GradientOverlay {
enabled: new_enabled,
opacity: 1.0, blend_mode: "Normal".to_string(),
angle: 90.0, scale: 1.0, gradient_type: 0,
},
"PatternOverlay" => LayerStyle::PatternOverlay {
enabled: new_enabled,
opacity: 1.0, blend_mode: "Normal".to_string(),
scale: 1.0, pattern_name: "".to_string(),
},
"Stroke" => LayerStyle::Stroke {
enabled: new_enabled,
size: 3.0, position: "Outside".to_string(),
opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(),
},
_ => unreachable!(),
};
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleSelect(index) => {
self.selected_style = index;
}
Message::LayerStyleApply => {
self.active_dialog = ActiveDialog::None;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleCancel => {
self.active_dialog = ActiveDialog::None;
}
Message::LayerStyleAdd => {
// TODO: open layer style add dialog
// Open layer style add dialog (placeholder)
}
Message::LayerStyleUpdateParam(index, param, value) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut new_style = style.clone();
// Update the specific parameter
match (&mut new_style, param.as_str()) {
(LayerStyle::DropShadow { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::DropShadow { angle, .. }, "angle") => *angle = value,
(LayerStyle::DropShadow { distance, .. }, "distance") => *distance = value,
(LayerStyle::DropShadow { spread, .. }, "spread") => *spread = value,
(LayerStyle::DropShadow { size, .. }, "size") => *size = value,
(LayerStyle::InnerShadow { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::InnerShadow { angle, .. }, "angle") => *angle = value,
(LayerStyle::InnerShadow { distance, .. }, "distance") => *distance = value,
(LayerStyle::InnerShadow { spread, .. }, "spread") => *spread = value,
(LayerStyle::InnerShadow { size, .. }, "size") => *size = value,
(LayerStyle::OuterGlow { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::OuterGlow { spread, .. }, "spread") => *spread = value,
(LayerStyle::OuterGlow { size, .. }, "size") => *size = value,
(LayerStyle::InnerGlow { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::InnerGlow { spread, .. }, "spread") => *spread = value,
(LayerStyle::InnerGlow { size, .. }, "size") => *size = value,
(LayerStyle::BevelEmboss { depth, .. }, "depth") => *depth = value,
(LayerStyle::BevelEmboss { size, .. }, "size") => *size = value,
(LayerStyle::BevelEmboss { angle, .. }, "angle") => *angle = value,
(LayerStyle::BevelEmboss { altitude, .. }, "altitude") => *altitude = value,
(LayerStyle::BevelEmboss { soften, .. }, "soften") => *soften = value,
(LayerStyle::Satin { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::Satin { angle, .. }, "angle") => *angle = value,
(LayerStyle::Satin { distance, .. }, "distance") => *distance = value,
(LayerStyle::Satin { size, .. }, "size") => *size = value,
(LayerStyle::ColorOverlay { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::GradientOverlay { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::GradientOverlay { angle, .. }, "angle") => *angle = value,
(LayerStyle::GradientOverlay { scale, .. }, "scale") => *scale = value,
(LayerStyle::PatternOverlay { opacity, .. }, "opacity") => *opacity = value,
(LayerStyle::PatternOverlay { scale, .. }, "scale") => *scale = value,
(LayerStyle::Stroke { size, .. }, "size") => *size = value,
(LayerStyle::Stroke { opacity, .. }, "opacity") => *opacity = value,
_ => {}
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateBlendMode(index, mode) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut new_style = style.clone();
match &mut new_style {
LayerStyle::DropShadow { blend_mode, .. }
| LayerStyle::InnerShadow { blend_mode, .. }
| LayerStyle::OuterGlow { blend_mode, .. }
| LayerStyle::InnerGlow { blend_mode, .. }
| LayerStyle::ColorOverlay { blend_mode, .. }
| LayerStyle::GradientOverlay { blend_mode, .. }
| LayerStyle::PatternOverlay { blend_mode, .. }
| LayerStyle::Stroke { blend_mode, .. } => *blend_mode = mode,
_ => {}
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style);
}
}
}
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerStyleUpdateColor(index, new_color) => {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
if index < disc_names.len() {
let disc = disc_names[index];
let existing = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
});
if let Some(style) = existing {
let mut updated_style = style.clone();
match &mut updated_style {
LayerStyle::DropShadow { color, .. }
| LayerStyle::InnerShadow { color, .. }
| LayerStyle::OuterGlow { color, .. }
| LayerStyle::InnerGlow { color, .. }
| LayerStyle::ColorOverlay { color, .. }
| LayerStyle::Satin { color, .. }
| LayerStyle::Stroke { color, .. } => *color = new_color,
LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color = new_color,
_ => {}
}
self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style);
}
}
}
// Update the HSL state for the picker
let r = new_color[0] as f32 / 255.0;
let g = new_color[1] as f32 / 255.0;
let b = new_color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) };
let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 };
self.style_color_hsl = (h, s, l);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::ShowStyleColorPicker(idx) => {
self.show_style_color_picker = true;
self.style_color_picker_idx = idx;
// Initialize HSL from current color
let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow",
"BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"];
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
if layer_id != 0 && idx < disc_names.len() {
let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id);
let disc = disc_names[idx];
let color = styles.iter().find(|s| {
matches!((disc, s),
("DropShadow", LayerStyle::DropShadow { .. })
| ("InnerShadow", LayerStyle::InnerShadow { .. })
| ("OuterGlow", LayerStyle::OuterGlow { .. })
| ("InnerGlow", LayerStyle::InnerGlow { .. })
| ("BevelEmboss", LayerStyle::BevelEmboss { .. })
| ("Satin", LayerStyle::Satin { .. })
| ("ColorOverlay", LayerStyle::ColorOverlay { .. })
| ("GradientOverlay", LayerStyle::GradientOverlay { .. })
| ("PatternOverlay", LayerStyle::PatternOverlay { .. })
| ("Stroke", LayerStyle::Stroke { .. })
)
}).map(|s| match s {
LayerStyle::DropShadow { color, .. }
| LayerStyle::InnerShadow { color, .. }
| LayerStyle::OuterGlow { color, .. }
| LayerStyle::InnerGlow { color, .. }
| LayerStyle::ColorOverlay { color, .. }
| LayerStyle::Satin { color, .. }
| LayerStyle::Stroke { color, .. } => *color,
LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color,
_ => [0, 0, 0, 255],
}).unwrap_or([0, 0, 0, 255]);
let r = color[0] as f32 / 255.0;
let g = color[1] as f32 / 255.0;
let b = color[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) };
let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 };
self.style_color_hsl = (h, s, l);
}
}
Message::HideStyleColorPicker => {
self.show_style_color_picker = false;
}
Message::OpenLayerStyleDialog => {
self.active_dialog = ActiveDialog::LayerStyleDialog;
self.layer_style_offset = (0.0, 0.0);
self.layer_style_dragging = false;
self.layer_style_drag_start = None;
}
Message::LayerStyleDialogDragStart(_x, _y) => {
// Drag started — begin tracking mouse movement.
// The actual offset computation uses delta from the last position.
self.layer_style_dragging = true;
}
Message::LayerStyleDialogDragMove(x, y) => {
if self.layer_style_dragging {
if let Some((lx, ly)) = self.layer_style_drag_start {
// Compute delta from last position and add to offset
let dx = x - lx;
let dy = y - ly;
self.layer_style_offset.0 += dx;
self.layer_style_offset.1 += dy;
}
self.layer_style_drag_start = Some((x, y));
}
}
Message::LayerStyleDialogDragEnd => {
self.layer_style_dragging = false;
self.layer_style_drag_start = None;
}
// ── Brushes ───────────────────────────────────
@@ -1317,7 +1803,18 @@ impl HcieIcedApp {
async {
rfd::AsyncFileDialog::new()
.set_title("Open Image")
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "psd", "kra", "hcie"])
.add_filter("All Supported Types", &[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
"hcie", "psd", "kra",
])
.add_filter("Images", &[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
])
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
@@ -1330,9 +1827,22 @@ impl HcieIcedApp {
async {
rfd::AsyncFileDialog::new()
.set_title("Save As")
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.add_filter("PNG", &["png"])
.add_filter("JPEG", &["jpg", "jpeg"])
.add_filter("WebP", &["webp"])
.add_filter("AVIF", &["avif"])
.add_filter("BMP", &["bmp"])
.add_filter("GIF", &["gif"])
.add_filter("TIFF", &["tiff", "tif"])
.add_filter("TGA", &["tga"])
.add_filter("OpenEXR", &["exr"])
.add_filter("All Supported Types", &[
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
"avif", "tga", "exr", "psd", "kra", "hcie",
])
.save_file()
.await
.map(|handle| handle.path().to_path_buf())
@@ -1348,14 +1858,22 @@ impl HcieIcedApp {
.to_lowercase();
if self.active_dialog == ActiveDialog::None {
// Open file
// Open file — route multi-layer formats to dedicated importers
let engine = &mut self.documents[self.active_doc].engine;
match engine.open_image(&path_str) {
let result = match ext.as_str() {
"psd" => engine.import_psd(&path_str),
"kra" => engine.import_kra(&path_str),
"hcie" => engine.load_native(&path_str),
_ => engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path);
self.documents[self.active_doc].source_path = Some(path.clone());
self.add_recent_file(&path);
self.documents[self.active_doc].full_upload.replace(true);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
@@ -1363,9 +1881,15 @@ impl HcieIcedApp {
Err(e) => log::error!("Failed to open: {}", e),
}
} else {
// Save file
// Save file — route multi-layer formats to dedicated exporters
let engine = &self.documents[self.active_doc].engine;
match engine.save_as(&path_str, &ext) {
let result = match ext.as_str() {
"hcie" => engine.save_native(&path_str),
"psd" => engine.export_psd(&path_str),
"kra" => engine.export_kra(&path_str),
_ => engine.save_as(&path_str, &ext),
};
match result {
Ok(()) => log::info!("Saved to {}", path_str),
Err(e) => log::error!("Failed to save: {}", e),
}
@@ -1445,7 +1969,18 @@ impl HcieIcedApp {
async {
rfd::AsyncFileDialog::new()
.set_title("Open Image")
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "psd", "kra", "hcie"])
.add_filter("All Supported Types", &[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
"hcie", "psd", "kra",
])
.add_filter("Images", &[
"png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif",
"avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr",
])
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
@@ -1467,9 +2002,22 @@ impl HcieIcedApp {
async {
rfd::AsyncFileDialog::new()
.set_title("Save As")
.add_filter("HCIE Native", &["hcie"])
.add_filter("Photoshop", &["psd"])
.add_filter("Krita", &["kra"])
.add_filter("PNG", &["png"])
.add_filter("JPEG", &["jpg", "jpeg"])
.add_filter("WebP", &["webp"])
.add_filter("AVIF", &["avif"])
.add_filter("BMP", &["bmp"])
.add_filter("GIF", &["gif"])
.add_filter("TIFF", &["tiff", "tif"])
.add_filter("TGA", &["tga"])
.add_filter("OpenEXR", &["exr"])
.add_filter("All Supported Types", &[
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif",
"avif", "tga", "exr", "psd", "kra", "hcie",
])
.save_file()
.await
.map(|handle| handle.path().to_path_buf())
@@ -1493,28 +2041,73 @@ impl HcieIcedApp {
self.active_dialog = ActiveDialog::None;
}
}
Message::OpenRecentFile(idx) => {
self.active_menu = None;
// Clone the path to avoid borrow conflict
let path_str = self.recent_files.get(idx).map(|e| e.path.clone());
if let Some(path_str) = path_str {
let path = std::path::PathBuf::from(&path_str);
if path.exists() {
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => self.documents[self.active_doc].engine.import_psd(&path_str),
"kra" => self.documents[self.active_doc].engine.import_kra(&path_str),
"hcie" => self.documents[self.active_doc].engine.load_native(&path_str),
_ => self.documents[self.active_doc].engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path.clone());
self.add_recent_file(&path);
let composite = self.documents[self.active_doc].engine.get_composite_pixels();
self.documents[self.active_doc].composite_raw = composite;
self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(self.documents[self.active_doc].composite_raw.clone());
self.documents[self.active_doc].full_upload.replace(true);
self.documents[self.active_doc].render_generation = self.documents[self.active_doc].render_generation.wrapping_add(1);
self.documents[self.active_doc].cached_layers = self.documents[self.active_doc].engine.layer_infos();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => log::error!("Failed to open recent file: {}", e),
}
} else {
self.recent_files.retain(|e| e.path != path_str);
}
}
}
Message::ClearRecentFiles => {
self.active_menu = None;
self.recent_files.clear();
save_recent_files(&self.recent_files);
}
Message::MenuAction(menu_idx, item_idx) => {
self.active_menu = None;
// Dispatch the appropriate action based on menu and item indices
match (menu_idx, item_idx) {
// ── File menu (0) ──
(0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open
// (0, 2) = separator
(0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save
(0, 4) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save As
// (0, 5) = separator
// (0, 6) = Import SVG — TODO
// (0, 7) = Import Brushes — TODO
// (0, 8) = separator
(0, 9) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG (placeholder → Save As)
(0, 10) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG (placeholder → Save As)
// (0, 11) = separator
(0, 12) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)), // Close
// (0, 13) = Exit
(0, 13) => {
if let Some(id) = self.window_id {
return iced::window::close(id);
(0, _) if item_idx >= 2 => {
let recent_count = self.recent_files.len().min(10);
let static_base = if recent_count > 0 { 4 + recent_count + 3 } else { 2 };
if item_idx >= static_base {
match item_idx - static_base {
0 => return Task::perform(async {}, |_| Message::SaveFileAs),
1 => return Task::perform(async {}, |_| Message::SaveFileAs),
6 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG
7 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG
9 => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)),
10 => {
if let Some(id) = self.window_id {
return iced::window::close(id);
}
}
_ => {}
}
}
}
@@ -1584,9 +2177,7 @@ impl HcieIcedApp {
(4, 4) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down
(4, 5) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image
// (4, 6) = separator
// (4, 7) = Clear Layer — TODO
// (4, 8) = separator
// (4, 9) = Layer Styles — TODO
(4, 7) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Styles
// (4, 10) = separator
// (4, 11) = Align Left — TODO
// (4, 12) = Align Center — TODO
@@ -1775,6 +2366,20 @@ impl HcieIcedApp {
1.0,
100.0,
),
ActiveDialog::LayerStyleDialog => {
let doc = &self.documents[self.active_doc];
let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id());
let styles: &'static [hcie_engine_api::LayerStyle] = Box::leak(styles.into_boxed_slice());
crate::panels::layer_styles::view(
styles,
self.selected_style,
colors,
self.show_style_color_picker,
self.style_color_picker_idx,
self.style_color_hsl,
self.layer_style_offset,
)
}
};
// Stack main content with overlays (dialog + menu dropdown).
@@ -1788,7 +2393,7 @@ impl HcieIcedApp {
if has_dialog {
stack = stack.push(dialog_overlay);
}
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu) {
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files) {
stack = stack.push(menu_overlay);
}
let _elapsed = view_start.elapsed();
@@ -1871,6 +2476,27 @@ impl HcieIcedApp {
}
});
iced::Subscription::batch(vec![keyboard])
// Mouse events for dialog dragging
let mouse = iced::event::listen_with(|event, _status, _id| {
if let iced::Event::Mouse(mouse_event) = event {
match mouse_event {
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => {
// Drag start is handled by mouse_area on_press
None
}
iced::mouse::Event::CursorMoved { position } => {
Some(Message::LayerStyleDialogDragMove(position.x, position.y))
}
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
Some(Message::LayerStyleDialogDragEnd)
}
_ => None,
}
} else {
None
}
});
iced::Subscription::batch(vec![keyboard, mouse])
}
}