mimo
This commit is contained in:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user