1582 lines
70 KiB
Rust
1582 lines
70 KiB
Rust
|
|
//! Core Iced application state and update logic.
|
||
|
|
//!
|
||
|
|
//! Implements the Elm-architecture pattern for the HCIE image editor.
|
||
|
|
//! Manages per-document engine instances, tool state, compositing,
|
||
|
|
//! dock layout, keyboard shortcuts, and all UI interactions.
|
||
|
|
|
||
|
|
use crate::dialogs;
|
||
|
|
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 iced::widget::{column, container, text};
|
||
|
|
use iced::{Element, Length, Task, Theme, Vector};
|
||
|
|
use std::sync::{Arc, Mutex};
|
||
|
|
|
||
|
|
/// Active dialog type.
|
||
|
|
#[derive(Debug, Clone, PartialEq)]
|
||
|
|
pub enum ActiveDialog {
|
||
|
|
None,
|
||
|
|
NewImage,
|
||
|
|
BrightnessContrast,
|
||
|
|
HueSaturation,
|
||
|
|
CloseConfirm,
|
||
|
|
SelectionOp(&'static str),
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Top-level application state.
|
||
|
|
pub struct HcieIcedApp {
|
||
|
|
/// All open documents.
|
||
|
|
pub documents: Vec<IcedDocument>,
|
||
|
|
/// Index of the currently active document.
|
||
|
|
pub active_doc: usize,
|
||
|
|
/// Current tool state.
|
||
|
|
pub tool_state: ToolState,
|
||
|
|
/// Foreground drawing color (RGBA).
|
||
|
|
pub fg_color: [u8; 4],
|
||
|
|
/// Background drawing color (RGBA).
|
||
|
|
pub bg_color: [u8; 4],
|
||
|
|
/// Last known cursor position from mouse_area events.
|
||
|
|
pub last_cursor_pos: Option<(f32, f32)>,
|
||
|
|
/// Cursor position in canvas-space for status bar display.
|
||
|
|
pub canvas_cursor_pos: Option<(u32, u32)>,
|
||
|
|
/// Currently active dialog (if any).
|
||
|
|
pub active_dialog: ActiveDialog,
|
||
|
|
/// New Image dialog state.
|
||
|
|
pub dialog_new_name: String,
|
||
|
|
pub dialog_new_width: u32,
|
||
|
|
pub dialog_new_height: u32,
|
||
|
|
pub dialog_new_transparent: bool,
|
||
|
|
/// Adjustment dialog state.
|
||
|
|
pub dialog_brightness: f32,
|
||
|
|
pub dialog_contrast: f32,
|
||
|
|
pub dialog_hue: f32,
|
||
|
|
pub dialog_saturation: f32,
|
||
|
|
pub dialog_lightness: f32,
|
||
|
|
/// Selection operation dialog state.
|
||
|
|
pub dialog_selection_value: f32,
|
||
|
|
/// Selected filter.
|
||
|
|
pub selected_filter: Option<FilterType>,
|
||
|
|
/// Filter parameters (JSON) for the currently selected filter.
|
||
|
|
pub filter_params: serde_json::Value,
|
||
|
|
/// Filter preview state.
|
||
|
|
pub filter_preview_active: bool,
|
||
|
|
/// Dock layout state.
|
||
|
|
pub dock: DockState,
|
||
|
|
/// Shared tablet state for pressure input.
|
||
|
|
pub tablet_state: Arc<Mutex<TabletState>>,
|
||
|
|
/// Whether the app has been initialized (load_path processed).
|
||
|
|
pub initialized: bool,
|
||
|
|
/// Theme state for consistent styling.
|
||
|
|
pub theme_state: ThemeState,
|
||
|
|
/// Currently open menu index (None = no menu open).
|
||
|
|
pub active_menu: Option<usize>,
|
||
|
|
/// Whether the toolbox is in two-column mode.
|
||
|
|
pub toolbox_two_column: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Per-document state wrapping an engine instance.
|
||
|
|
pub struct IcedDocument {
|
||
|
|
pub engine: Engine,
|
||
|
|
/// Cached composite RGBA pixel data from the engine.
|
||
|
|
pub composite_buffer: Vec<u8>,
|
||
|
|
/// Whether the composite needs re-rendering.
|
||
|
|
pub composite_dirty: bool,
|
||
|
|
/// Document display name.
|
||
|
|
pub name: String,
|
||
|
|
/// Original file path, if loaded from disk.
|
||
|
|
pub source_path: Option<std::path::PathBuf>,
|
||
|
|
/// Whether the document has unsaved changes.
|
||
|
|
pub modified: bool,
|
||
|
|
/// Per-document zoom level.
|
||
|
|
pub zoom: f32,
|
||
|
|
/// Per-document pan offset in screen pixels.
|
||
|
|
pub pan_offset: Vector,
|
||
|
|
/// Cached layer infos for the layers panel.
|
||
|
|
pub cached_layers: Vec<hcie_engine_api::LayerInfo>,
|
||
|
|
/// Cached history items for the history panel.
|
||
|
|
pub cached_history: Vec<(usize, String)>,
|
||
|
|
/// Current history index.
|
||
|
|
pub history_current: i32,
|
||
|
|
/// Selection rectangle in canvas coordinates (for select tool).
|
||
|
|
pub selection_rect: Option<(f32, f32, f32, f32)>,
|
||
|
|
/// Vector shape being drawn (start pos, current pos).
|
||
|
|
pub vector_draw: Option<((f32, f32), (f32, f32))>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Drawing tool state.
|
||
|
|
pub struct ToolState {
|
||
|
|
/// Currently selected tool.
|
||
|
|
pub active_tool: Tool,
|
||
|
|
/// Whether the user is currently drawing (mouse button held).
|
||
|
|
pub is_drawing: bool,
|
||
|
|
/// Canvas-space coordinates of the last pointer event during a stroke.
|
||
|
|
pub last_stroke_pos: Option<(f32, f32)>,
|
||
|
|
/// Accumulated distance for the current stroke (for pressure interpolation).
|
||
|
|
pub brush_accumulated_dist: f32,
|
||
|
|
/// Current brush size.
|
||
|
|
pub brush_size: f32,
|
||
|
|
/// Current brush opacity.
|
||
|
|
pub brush_opacity: f32,
|
||
|
|
/// Current brush hardness.
|
||
|
|
pub brush_hardness: f32,
|
||
|
|
/// Current brush style.
|
||
|
|
pub brush_style: BrushStyle,
|
||
|
|
/// Current brush spacing.
|
||
|
|
pub brush_spacing: f32,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Default for ToolState {
|
||
|
|
fn default() -> Self {
|
||
|
|
Self {
|
||
|
|
active_tool: Tool::Brush,
|
||
|
|
is_drawing: false,
|
||
|
|
last_stroke_pos: None,
|
||
|
|
brush_accumulated_dist: 0.0,
|
||
|
|
brush_size: 20.0,
|
||
|
|
brush_opacity: 1.0,
|
||
|
|
brush_hardness: 0.8,
|
||
|
|
brush_style: BrushStyle::Round,
|
||
|
|
brush_spacing: 1.0,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Messages the application handles.
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub enum Message {
|
||
|
|
// ── Tool selection ──────────────────────────────────
|
||
|
|
ToolSelected(Tool),
|
||
|
|
|
||
|
|
// ── Color ───────────────────────────────────────────
|
||
|
|
FgColorChanged([u8; 4]),
|
||
|
|
BgColorChanged([u8; 4]),
|
||
|
|
SwapColors,
|
||
|
|
|
||
|
|
// ── Canvas interaction ──────────────────────────────
|
||
|
|
CanvasPointerPressed { x: f32, y: f32 },
|
||
|
|
CanvasPointerMoved { x: f32, y: f32 },
|
||
|
|
CanvasPointerReleased,
|
||
|
|
CanvasZoom { delta: f32 },
|
||
|
|
CanvasZoomSet(f32),
|
||
|
|
|
||
|
|
// ── Engine operations ───────────────────────────────
|
||
|
|
CompositeRefresh,
|
||
|
|
Undo,
|
||
|
|
Redo,
|
||
|
|
|
||
|
|
// ── Layer operations ────────────────────────────────
|
||
|
|
LayerSelect(u64),
|
||
|
|
LayerAdd,
|
||
|
|
LayerDelete(u64),
|
||
|
|
LayerToggleVisibility(u64),
|
||
|
|
LayerToggleLock(u64),
|
||
|
|
LayerSetOpacity(u64, f32),
|
||
|
|
LayerMergeDown,
|
||
|
|
LayerFlatten,
|
||
|
|
LayerMoveUp(u64),
|
||
|
|
LayerMoveDown(u64),
|
||
|
|
|
||
|
|
// ── History ─────────────────────────────────────────
|
||
|
|
HistoryJumpTo(i32),
|
||
|
|
|
||
|
|
// ── Text tool ───────────────────────────────────────
|
||
|
|
TextSizeChanged(f32),
|
||
|
|
TextColorChanged([u8; 4]),
|
||
|
|
TextAlignChanged(String),
|
||
|
|
TextAccept,
|
||
|
|
TextCancel,
|
||
|
|
|
||
|
|
// ── Layer styles ────────────────────────────────────
|
||
|
|
LayerStyleToggle(u32),
|
||
|
|
LayerStyleAdd,
|
||
|
|
|
||
|
|
// ── Brushes ─────────────────────────────────────────
|
||
|
|
BrushStyleSelected(BrushStyle),
|
||
|
|
BrushSizeChanged(f32),
|
||
|
|
BrushOpacityChanged(f32),
|
||
|
|
BrushHardnessChanged(f32),
|
||
|
|
|
||
|
|
// ── Filters ─────────────────────────────────────────
|
||
|
|
FilterSelect(FilterType),
|
||
|
|
FilterApply,
|
||
|
|
FilterReset,
|
||
|
|
FilterParamChanged(String, serde_json::Value),
|
||
|
|
|
||
|
|
// ── Dialogs ─────────────────────────────────────────
|
||
|
|
DialogOpen(ActiveDialog),
|
||
|
|
DialogClose,
|
||
|
|
DialogCloseSave,
|
||
|
|
DialogCloseDiscard,
|
||
|
|
|
||
|
|
// ── New Image Dialog ────────────────────────────────
|
||
|
|
DialogNewImageName(String),
|
||
|
|
DialogNewImageWidth(u32),
|
||
|
|
DialogNewImageHeight(u32),
|
||
|
|
DialogNewImageTransparent(bool),
|
||
|
|
DialogNewImagePreset(u32, u32),
|
||
|
|
DialogNewImageCreate,
|
||
|
|
|
||
|
|
// ── Adjustments Dialog ──────────────────────────────
|
||
|
|
AdjBrightness(f32),
|
||
|
|
AdjContrast(f32),
|
||
|
|
AdjHue(f32),
|
||
|
|
AdjSaturation(f32),
|
||
|
|
AdjLightness(f32),
|
||
|
|
AdjApply,
|
||
|
|
|
||
|
|
// ── Selection Dialog ────────────────────────────────
|
||
|
|
SelectionOpValue(f32),
|
||
|
|
SelectionOpApply,
|
||
|
|
|
||
|
|
// ── Selection tools ─────────────────────────────────
|
||
|
|
SelectionDragStart(f32, f32),
|
||
|
|
SelectionDragMove(f32, f32),
|
||
|
|
SelectionDragEnd,
|
||
|
|
|
||
|
|
// ── Vector tools ────────────────────────────────────
|
||
|
|
VectorDrawStart(f32, f32),
|
||
|
|
VectorDrawMove(f32, f32),
|
||
|
|
VectorDrawEnd,
|
||
|
|
|
||
|
|
// ── AI Chat ─────────────────────────────────────────
|
||
|
|
AiChatInput(String),
|
||
|
|
AiChatSend,
|
||
|
|
AiChatClear,
|
||
|
|
|
||
|
|
// ── Clipboard ───────────────────────────────────────
|
||
|
|
CopyImage,
|
||
|
|
PasteImage,
|
||
|
|
CopyText(String),
|
||
|
|
PasteText,
|
||
|
|
ClipboardResult(Result<Option<String>, String>),
|
||
|
|
ImageCopied(Result<(), String>),
|
||
|
|
ImagePasted(Result<Option<(Vec<u8>, u32, u32)>, String>),
|
||
|
|
|
||
|
|
// ── File I/O (with rfd) ─────────────────────────────
|
||
|
|
OpenFileRfd,
|
||
|
|
SaveFileAs,
|
||
|
|
FileDialogClosed(Option<std::path::PathBuf>),
|
||
|
|
|
||
|
|
// ── Dock Layout ─────────────────────────────────────
|
||
|
|
PaneClicked(iced::widget::pane_grid::Pane),
|
||
|
|
PaneDragged(iced::widget::pane_grid::DragEvent),
|
||
|
|
PaneResized(iced::widget::pane_grid::ResizeEvent),
|
||
|
|
PaneClose(iced::widget::pane_grid::Pane),
|
||
|
|
PaneMaximize(iced::widget::pane_grid::Pane),
|
||
|
|
|
||
|
|
// ── File I/O ────────────────────────────────────────
|
||
|
|
NewDocument(u32, u32),
|
||
|
|
DocSwitch(usize),
|
||
|
|
OpenFile,
|
||
|
|
FileOpened(Result<std::path::PathBuf, String>),
|
||
|
|
SaveFile,
|
||
|
|
|
||
|
|
// ── Menu ─────────────────────────────────────────────
|
||
|
|
MenuOpen(usize),
|
||
|
|
MenuClose,
|
||
|
|
MenuAction(usize, usize), // (menu_index, item_index)
|
||
|
|
|
||
|
|
// ── Toolbox ─────────────────────────────────────────
|
||
|
|
ToggleToolbox,
|
||
|
|
|
||
|
|
// ── Misc ────────────────────────────────────────────
|
||
|
|
NoOp,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Convert a FilterType enum to the engine's string filter ID.
|
||
|
|
fn filter_type_to_id(filter: FilterType) -> &'static str {
|
||
|
|
match filter {
|
||
|
|
FilterType::BoxBlur => "box_blur",
|
||
|
|
FilterType::GaussianBlur => "gaussian_blur",
|
||
|
|
FilterType::MotionBlur => "motion_blur",
|
||
|
|
FilterType::UnsharpMask => "sharpen",
|
||
|
|
FilterType::Mosaic => "mosaic",
|
||
|
|
FilterType::Pinch => "pinch",
|
||
|
|
FilterType::Twirl => "twirl",
|
||
|
|
FilterType::OilPaint => "oil_paint",
|
||
|
|
FilterType::Crystallize => "crystallize",
|
||
|
|
FilterType::Levels => "levels",
|
||
|
|
FilterType::Vibrance => "vibrance",
|
||
|
|
FilterType::Exposure => "exposure",
|
||
|
|
FilterType::Posterize => "posterize",
|
||
|
|
FilterType::Threshold => "threshold",
|
||
|
|
FilterType::ChannelMixer => "channel_mixer",
|
||
|
|
FilterType::BlackAndWhite => "black_and_white",
|
||
|
|
FilterType::SelectiveColor => "selective_color",
|
||
|
|
FilterType::GammaCorrection => "gamma_correction",
|
||
|
|
FilterType::ExtractChannel => "extract_channel",
|
||
|
|
FilterType::GradientMap => "gradient_map",
|
||
|
|
FilterType::GaussianBlurGamma => "gaussian_blur_gamma",
|
||
|
|
FilterType::BoxBlurGamma => "box_blur_gamma",
|
||
|
|
FilterType::MedianFilter => "median_filter",
|
||
|
|
FilterType::Dehaze => "dehaze",
|
||
|
|
FilterType::NoisePattern => "noise_pattern",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Build a BrushTip from the current tool state.
|
||
|
|
///
|
||
|
|
/// Converts the tool state's brush parameters into an engine BrushTip
|
||
|
|
/// that can be passed to `engine.set_brush_tip()` before starting a stroke.
|
||
|
|
fn build_brush_tip(state: &ToolState) -> BrushTip {
|
||
|
|
BrushTip {
|
||
|
|
style: state.brush_style,
|
||
|
|
size: state.brush_size,
|
||
|
|
opacity: state.brush_opacity,
|
||
|
|
hardness: state.brush_hardness,
|
||
|
|
spacing: state.brush_spacing,
|
||
|
|
..BrushTip::default()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl HcieIcedApp {
|
||
|
|
/// Create the initial application state.
|
||
|
|
///
|
||
|
|
/// If `load_path` is provided, the file will be opened after initialization.
|
||
|
|
pub fn new(load_path: Option<std::path::PathBuf>) -> Self {
|
||
|
|
let mut engine = Engine::new(800, 600);
|
||
|
|
engine.pre_tile_all_layers();
|
||
|
|
let composite_buffer = engine.get_composite_pixels();
|
||
|
|
|
||
|
|
let cached_layers = engine.layer_infos();
|
||
|
|
let history_len = engine.history_len();
|
||
|
|
let cached_history: Vec<(usize, String)> = (0..history_len)
|
||
|
|
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
||
|
|
.collect();
|
||
|
|
let history_current = engine.history_current();
|
||
|
|
|
||
|
|
let doc = IcedDocument {
|
||
|
|
engine,
|
||
|
|
composite_buffer,
|
||
|
|
composite_dirty: false,
|
||
|
|
name: "Untitled".to_string(),
|
||
|
|
source_path: None,
|
||
|
|
modified: false,
|
||
|
|
zoom: 1.0,
|
||
|
|
pan_offset: Vector::ZERO,
|
||
|
|
cached_layers,
|
||
|
|
cached_history,
|
||
|
|
history_current,
|
||
|
|
selection_rect: None,
|
||
|
|
vector_draw: None,
|
||
|
|
};
|
||
|
|
|
||
|
|
let mut app = Self {
|
||
|
|
documents: vec![doc],
|
||
|
|
active_doc: 0,
|
||
|
|
tool_state: ToolState::default(),
|
||
|
|
fg_color: [220, 50, 50, 255],
|
||
|
|
bg_color: [255, 255, 255, 255],
|
||
|
|
last_cursor_pos: None,
|
||
|
|
canvas_cursor_pos: None,
|
||
|
|
active_dialog: ActiveDialog::None,
|
||
|
|
dialog_new_name: "Untitled".to_string(),
|
||
|
|
dialog_new_width: 800,
|
||
|
|
dialog_new_height: 600,
|
||
|
|
dialog_new_transparent: true,
|
||
|
|
dialog_brightness: 0.0,
|
||
|
|
dialog_contrast: 0.0,
|
||
|
|
dialog_hue: 0.0,
|
||
|
|
dialog_saturation: 0.0,
|
||
|
|
dialog_lightness: 0.0,
|
||
|
|
dialog_selection_value: 5.0,
|
||
|
|
selected_filter: None,
|
||
|
|
filter_params: serde_json::json!({}),
|
||
|
|
filter_preview_active: false,
|
||
|
|
dock: DockState::new(),
|
||
|
|
tablet_state: Arc::new(Mutex::new(TabletState::new())),
|
||
|
|
initialized: false,
|
||
|
|
theme_state: ThemeState::new(),
|
||
|
|
active_menu: None,
|
||
|
|
toolbox_two_column: false,
|
||
|
|
};
|
||
|
|
|
||
|
|
// If a file path was provided, open it
|
||
|
|
if let Some(path) = load_path {
|
||
|
|
let path_str = path.to_string_lossy().to_string();
|
||
|
|
match app.documents[0].engine.open_image(&path_str) {
|
||
|
|
Ok(()) => {
|
||
|
|
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].composite_dirty = true;
|
||
|
|
app.refresh_composite_if_needed();
|
||
|
|
}
|
||
|
|
Err(e) => {
|
||
|
|
log::error!("Failed to open file on startup: {}", e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
app.initialized = true;
|
||
|
|
app
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Return a mutable reference to the active document.
|
||
|
|
fn active_document_mut(&mut self) -> &mut IcedDocument {
|
||
|
|
self.documents.get_mut(self.active_doc).expect("no active document")
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Convert widget-relative coordinates to canvas-space coordinates.
|
||
|
|
fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
|
||
|
|
let doc = &self.documents[self.active_doc];
|
||
|
|
let zoom = doc.zoom;
|
||
|
|
let pan = doc.pan_offset;
|
||
|
|
|
||
|
|
let canvas_x = (sx - pan.x) / zoom;
|
||
|
|
let canvas_y = (sy - pan.y) / zoom;
|
||
|
|
|
||
|
|
if canvas_x >= 0.0 && canvas_x < doc.engine.canvas_width() as f32
|
||
|
|
&& canvas_y >= 0.0 && canvas_y < doc.engine.canvas_height() as f32
|
||
|
|
{
|
||
|
|
Some((canvas_x, canvas_y))
|
||
|
|
} else {
|
||
|
|
None
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Refresh the composite buffer and cached panel data from the engine if dirty.
|
||
|
|
fn refresh_composite_if_needed(&mut self) {
|
||
|
|
let doc = &mut self.documents[self.active_doc];
|
||
|
|
if doc.composite_dirty {
|
||
|
|
doc.composite_buffer = doc.engine.get_composite_pixels();
|
||
|
|
doc.composite_dirty = false;
|
||
|
|
}
|
||
|
|
// Always refresh cached panel data (cheap operation)
|
||
|
|
doc.cached_layers = doc.engine.layer_infos();
|
||
|
|
let history_len = doc.engine.history_len();
|
||
|
|
doc.cached_history = (0..history_len)
|
||
|
|
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
|
||
|
|
.collect();
|
||
|
|
doc.history_current = doc.engine.history_current();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Apply brush parameters from tool state to the engine.
|
||
|
|
///
|
||
|
|
/// Called before starting a stroke to ensure the engine uses
|
||
|
|
/// the current brush size, opacity, hardness, and style.
|
||
|
|
fn apply_brush_params(&mut self) {
|
||
|
|
let tip = build_brush_tip(&self.tool_state);
|
||
|
|
self.documents[self.active_doc].engine.set_brush_tip(tip);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Update ──────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Handle a message and return an optional command.
|
||
|
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||
|
|
match message {
|
||
|
|
Message::ToolSelected(tool) => {
|
||
|
|
log::info!("Tool selected: {:?}", tool);
|
||
|
|
self.tool_state.active_tool = tool;
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::FgColorChanged(color) => {
|
||
|
|
self.fg_color = color;
|
||
|
|
self.active_document_mut().engine.set_color(color);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::BgColorChanged(color) => {
|
||
|
|
self.bg_color = color;
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::SwapColors => {
|
||
|
|
std::mem::swap(&mut self.fg_color, &mut self.bg_color);
|
||
|
|
let color = self.fg_color;
|
||
|
|
self.active_document_mut().engine.set_color(color);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::CanvasPointerPressed { x, y } => {
|
||
|
|
let (px, py) = if x == 0.0 && y == 0.0 {
|
||
|
|
self.last_cursor_pos.unwrap_or((0.0, 0.0))
|
||
|
|
} else {
|
||
|
|
(x, y)
|
||
|
|
};
|
||
|
|
self.last_cursor_pos = Some((px, py));
|
||
|
|
|
||
|
|
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(px, py) {
|
||
|
|
self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
|
||
|
|
let tool = self.tool_state.active_tool;
|
||
|
|
|
||
|
|
match tool {
|
||
|
|
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
||
|
|
// Apply brush parameters before starting stroke
|
||
|
|
self.apply_brush_params();
|
||
|
|
|
||
|
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
|
|
self.documents[self.active_doc].engine.set_tool(tool);
|
||
|
|
self.documents[self.active_doc].engine.set_eraser(tool == Tool::Eraser);
|
||
|
|
self.documents[self.active_doc].engine.begin_stroke(layer_id, canvas_x, canvas_y);
|
||
|
|
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, 1.0);
|
||
|
|
self.tool_state.is_drawing = true;
|
||
|
|
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
|
||
|
|
self.tool_state.brush_accumulated_dist = 0.0;
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Tool::Eyedropper => {
|
||
|
|
let cx = canvas_x as u32;
|
||
|
|
let cy = canvas_y as u32;
|
||
|
|
let w = self.documents[self.active_doc].engine.canvas_width();
|
||
|
|
let h = self.documents[self.active_doc].engine.canvas_height();
|
||
|
|
if cx < w && cy < h {
|
||
|
|
let pixels = &self.documents[self.active_doc].composite_buffer;
|
||
|
|
let idx = ((cy * w + cx) * 4) as usize;
|
||
|
|
if idx + 3 < pixels.len() {
|
||
|
|
let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
|
||
|
|
self.fg_color = color;
|
||
|
|
self.documents[self.active_doc].engine.set_color(color);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Tool::FloodFill => {
|
||
|
|
let cx = canvas_x as u32;
|
||
|
|
let cy = canvas_y as u32;
|
||
|
|
let color = self.fg_color;
|
||
|
|
self.documents[self.active_doc].engine.flood_fill(cx, cy, color, 32);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Tool::Select => {
|
||
|
|
// Start selection rectangle drag
|
||
|
|
let sx = canvas_x;
|
||
|
|
let sy = canvas_y;
|
||
|
|
return Task::perform(async move {}, move |_| {
|
||
|
|
Message::SelectionDragStart(sx, sy)
|
||
|
|
});
|
||
|
|
}
|
||
|
|
Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine => {
|
||
|
|
// Start vector shape drawing
|
||
|
|
let sx = canvas_x;
|
||
|
|
let sy = canvas_y;
|
||
|
|
return Task::perform(async move {}, move |_| {
|
||
|
|
Message::VectorDrawStart(sx, sy)
|
||
|
|
});
|
||
|
|
}
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::CanvasPointerMoved { x, y } => {
|
||
|
|
self.last_cursor_pos = Some((x, y));
|
||
|
|
|
||
|
|
// Update canvas-space cursor for status bar
|
||
|
|
if let Some((cx, cy)) = self.screen_to_canvas(x, y) {
|
||
|
|
self.canvas_cursor_pos = Some((cx as u32, cy as u32));
|
||
|
|
}
|
||
|
|
|
||
|
|
if self.tool_state.is_drawing {
|
||
|
|
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(x, y) {
|
||
|
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
|
|
|
||
|
|
let spacing: f32 = match self.tool_state.active_tool {
|
||
|
|
Tool::Spray => 2.0_f32,
|
||
|
|
_ => 1.0_f32,
|
||
|
|
}.max(1.0);
|
||
|
|
|
||
|
|
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
|
||
|
|
let dx = canvas_x - last_x;
|
||
|
|
let dy = canvas_y - last_y;
|
||
|
|
let dist = (dx * dx + dy * dy).sqrt();
|
||
|
|
|
||
|
|
// Use constant pressure for mouse input
|
||
|
|
// Real tablet pressure comes from tablet_state
|
||
|
|
let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
|
||
|
|
|
||
|
|
if dist >= spacing {
|
||
|
|
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, pressure);
|
||
|
|
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, 1.0);
|
||
|
|
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle selection drag
|
||
|
|
if self.tool_state.active_tool == Tool::Select {
|
||
|
|
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(x, y) {
|
||
|
|
let mx = canvas_x;
|
||
|
|
let my = canvas_y;
|
||
|
|
return Task::perform(async move {}, move |_| {
|
||
|
|
Message::SelectionDragMove(mx, my)
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle vector draw drag
|
||
|
|
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
|
||
|
|
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(x, y) {
|
||
|
|
let mx = canvas_x;
|
||
|
|
let my = canvas_y;
|
||
|
|
return Task::perform(async move {}, move |_| {
|
||
|
|
Message::VectorDrawMove(mx, my)
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::CanvasPointerReleased => {
|
||
|
|
if self.tool_state.is_drawing {
|
||
|
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||
|
|
self.documents[self.active_doc].engine.end_stroke(layer_id);
|
||
|
|
self.documents[self.active_doc].engine.commit_pending_history();
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
|
||
|
|
self.tool_state.is_drawing = false;
|
||
|
|
self.tool_state.last_stroke_pos = None;
|
||
|
|
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
// End selection drag
|
||
|
|
if self.tool_state.active_tool == Tool::Select {
|
||
|
|
return Task::perform(async {}, |_| Message::SelectionDragEnd);
|
||
|
|
}
|
||
|
|
|
||
|
|
// End vector draw
|
||
|
|
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
|
||
|
|
return Task::perform(async {}, |_| Message::VectorDrawEnd);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::CanvasZoom { delta } => {
|
||
|
|
let doc = &mut self.documents[self.active_doc];
|
||
|
|
let factor = if delta > 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||
|
|
doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::CanvasZoomSet(z) => {
|
||
|
|
self.documents[self.active_doc].zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::CompositeRefresh | Message::Undo | Message::Redo => {
|
||
|
|
match message {
|
||
|
|
Message::Undo => { self.documents[self.active_doc].engine.undo(); }
|
||
|
|
Message::Redo => { self.documents[self.active_doc].engine.redo(); }
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Layer operations ──────────────────────────
|
||
|
|
Message::LayerSelect(id) => {
|
||
|
|
self.documents[self.active_doc].engine.set_active_layer(id);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerAdd => {
|
||
|
|
let count = self.documents[self.active_doc].engine.get_layer_count();
|
||
|
|
self.documents[self.active_doc].engine.add_layer(&format!("Layer {}", count + 1));
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerDelete(id) => {
|
||
|
|
self.documents[self.active_doc].engine.delete_layer(id);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerToggleVisibility(id) => {
|
||
|
|
let visible = self.documents[self.active_doc].engine.get_layer_info(id)
|
||
|
|
.map(|l| !l.visible)
|
||
|
|
.unwrap_or(true);
|
||
|
|
self.documents[self.active_doc].engine.set_layer_visible(id, visible);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerToggleLock(id) => {
|
||
|
|
let locked = self.documents[self.active_doc].engine.get_layer_info(id)
|
||
|
|
.map(|l| !l.locked)
|
||
|
|
.unwrap_or(false);
|
||
|
|
self.documents[self.active_doc].engine.set_layer_locked(id, locked);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerSetOpacity(id, opacity) => {
|
||
|
|
self.documents[self.active_doc].engine.set_layer_opacity(id, opacity);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerMergeDown => {
|
||
|
|
self.documents[self.active_doc].engine.merge_down();
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerFlatten => {
|
||
|
|
self.documents[self.active_doc].engine.flatten_image();
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerMoveUp(id) => {
|
||
|
|
let infos = self.documents[self.active_doc].engine.layer_infos();
|
||
|
|
if let Some(pos) = infos.iter().position(|l| l.id == id) {
|
||
|
|
if pos > 0 {
|
||
|
|
let target_idx = pos - 1;
|
||
|
|
self.documents[self.active_doc].engine.move_layer(id, target_idx);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::LayerMoveDown(id) => {
|
||
|
|
let infos = self.documents[self.active_doc].engine.layer_infos();
|
||
|
|
if let Some(pos) = infos.iter().position(|l| l.id == id) {
|
||
|
|
if pos + 1 < infos.len() {
|
||
|
|
let target_idx = pos + 1;
|
||
|
|
self.documents[self.active_doc].engine.move_layer(id, target_idx);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── History ───────────────────────────────────
|
||
|
|
Message::HistoryJumpTo(idx) => {
|
||
|
|
self.documents[self.active_doc].engine.jump_to_history(idx);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Text tool ─────────────────────────────────
|
||
|
|
Message::TextSizeChanged(_size) => {
|
||
|
|
// TODO: apply to active text tool config
|
||
|
|
}
|
||
|
|
Message::TextColorChanged(_color) => {
|
||
|
|
// TODO: apply to active text tool config
|
||
|
|
}
|
||
|
|
Message::TextAlignChanged(_alignment) => {
|
||
|
|
// TODO: apply to active text tool config
|
||
|
|
}
|
||
|
|
Message::TextAccept => {
|
||
|
|
// TODO: commit text changes
|
||
|
|
}
|
||
|
|
Message::TextCancel => {
|
||
|
|
// TODO: cancel text changes
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Layer styles ──────────────────────────────
|
||
|
|
Message::LayerStyleToggle(_index) => {
|
||
|
|
// TODO: toggle layer style enabled state
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Message::LayerStyleAdd => {
|
||
|
|
// TODO: open layer style add dialog
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Brushes ───────────────────────────────────
|
||
|
|
Message::BrushStyleSelected(style) => {
|
||
|
|
log::info!("Brush style selected: {:?}", style);
|
||
|
|
self.tool_state.brush_style = style;
|
||
|
|
}
|
||
|
|
Message::BrushSizeChanged(size) => {
|
||
|
|
self.tool_state.brush_size = size;
|
||
|
|
}
|
||
|
|
Message::BrushOpacityChanged(opacity) => {
|
||
|
|
self.tool_state.brush_opacity = opacity;
|
||
|
|
}
|
||
|
|
Message::BrushHardnessChanged(hardness) => {
|
||
|
|
self.tool_state.brush_hardness = hardness;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Filters ───────────────────────────────────
|
||
|
|
Message::FilterSelect(filter) => {
|
||
|
|
self.selected_filter = Some(filter);
|
||
|
|
self.filter_preview_active = true;
|
||
|
|
self.filter_params = serde_json::json!({});
|
||
|
|
self.documents[self.active_doc].engine.filter_preview_begin();
|
||
|
|
}
|
||
|
|
Message::FilterParamChanged(key, value) => {
|
||
|
|
// Update the specific parameter in filter_params
|
||
|
|
if let serde_json::Value::Object(ref mut map) = self.filter_params {
|
||
|
|
map.insert(key, value);
|
||
|
|
}
|
||
|
|
// Apply preview with updated params
|
||
|
|
if let Some(filter) = self.selected_filter {
|
||
|
|
let filter_id = filter_type_to_id(filter);
|
||
|
|
self.documents[self.active_doc].engine.apply_filter_preview(filter_id, self.filter_params.clone());
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Message::FilterApply => {
|
||
|
|
if let Some(filter) = self.selected_filter {
|
||
|
|
let filter_id = filter_type_to_id(filter);
|
||
|
|
self.documents[self.active_doc].engine.apply_filter(filter_id, self.filter_params.clone());
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
self.filter_preview_active = false;
|
||
|
|
self.selected_filter = None;
|
||
|
|
self.filter_params = serde_json::json!({});
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Message::FilterReset => {
|
||
|
|
if self.filter_preview_active {
|
||
|
|
self.documents[self.active_doc].engine.filter_preview_restore();
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
self.filter_preview_active = false;
|
||
|
|
}
|
||
|
|
self.selected_filter = None;
|
||
|
|
self.filter_params = serde_json::json!({});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Dialogs ───────────────────────────────────
|
||
|
|
Message::DialogOpen(dialog) => {
|
||
|
|
self.active_dialog = dialog;
|
||
|
|
}
|
||
|
|
Message::DialogClose => {
|
||
|
|
self.active_dialog = ActiveDialog::None;
|
||
|
|
}
|
||
|
|
Message::DialogCloseSave => {
|
||
|
|
// TODO: save then close
|
||
|
|
self.active_dialog = ActiveDialog::None;
|
||
|
|
}
|
||
|
|
Message::DialogCloseDiscard => {
|
||
|
|
self.active_dialog = ActiveDialog::None;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── New Image Dialog ──────────────────────────
|
||
|
|
Message::DialogNewImageName(name) => {
|
||
|
|
self.dialog_new_name = name;
|
||
|
|
}
|
||
|
|
Message::DialogNewImageWidth(w) => {
|
||
|
|
self.dialog_new_width = w;
|
||
|
|
}
|
||
|
|
Message::DialogNewImageHeight(h) => {
|
||
|
|
self.dialog_new_height = h;
|
||
|
|
}
|
||
|
|
Message::DialogNewImageTransparent(t) => {
|
||
|
|
self.dialog_new_transparent = t;
|
||
|
|
}
|
||
|
|
Message::DialogNewImagePreset(w, h) => {
|
||
|
|
self.dialog_new_width = w;
|
||
|
|
self.dialog_new_height = h;
|
||
|
|
}
|
||
|
|
Message::DialogNewImageCreate => {
|
||
|
|
let w = self.dialog_new_width;
|
||
|
|
let h = self.dialog_new_height;
|
||
|
|
let name = self.dialog_new_name.clone();
|
||
|
|
let transparent = self.dialog_new_transparent;
|
||
|
|
let mut engine = Engine::new_with_options(&name, w, h, transparent);
|
||
|
|
engine.pre_tile_all_layers();
|
||
|
|
let composite_buffer = engine.get_composite_pixels();
|
||
|
|
let cached_layers = engine.layer_infos();
|
||
|
|
let history_len = engine.history_len();
|
||
|
|
let cached_history: Vec<(usize, String)> = (0..history_len)
|
||
|
|
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
||
|
|
.collect();
|
||
|
|
let history_current = engine.history_current();
|
||
|
|
self.documents.push(IcedDocument {
|
||
|
|
engine,
|
||
|
|
composite_buffer,
|
||
|
|
composite_dirty: false,
|
||
|
|
name,
|
||
|
|
source_path: None,
|
||
|
|
modified: false,
|
||
|
|
zoom: 1.0,
|
||
|
|
pan_offset: Vector::ZERO,
|
||
|
|
cached_layers,
|
||
|
|
cached_history,
|
||
|
|
history_current,
|
||
|
|
selection_rect: None,
|
||
|
|
vector_draw: None,
|
||
|
|
});
|
||
|
|
self.active_doc = self.documents.len() - 1;
|
||
|
|
self.active_dialog = ActiveDialog::None;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Adjustments Dialog ────────────────────────
|
||
|
|
Message::AdjBrightness(v) => {
|
||
|
|
self.dialog_brightness = v;
|
||
|
|
let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast});
|
||
|
|
self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Message::AdjContrast(v) => {
|
||
|
|
self.dialog_contrast = v;
|
||
|
|
let params = serde_json::json!({"brightness": self.dialog_brightness, "contrast": v});
|
||
|
|
self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Message::AdjHue(v) => {
|
||
|
|
self.dialog_hue = v;
|
||
|
|
let params = serde_json::json!({"hue": v, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness});
|
||
|
|
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Message::AdjSaturation(v) => {
|
||
|
|
self.dialog_saturation = v;
|
||
|
|
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": v, "lightness": self.dialog_lightness});
|
||
|
|
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Message::AdjLightness(v) => {
|
||
|
|
self.dialog_lightness = v;
|
||
|
|
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": v});
|
||
|
|
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Message::AdjApply => {
|
||
|
|
// Apply the adjustment permanently (not just preview)
|
||
|
|
let params = match &self.active_dialog {
|
||
|
|
ActiveDialog::BrightnessContrast => {
|
||
|
|
serde_json::json!({"brightness": self.dialog_brightness, "contrast": self.dialog_contrast})
|
||
|
|
}
|
||
|
|
ActiveDialog::HueSaturation => {
|
||
|
|
serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness})
|
||
|
|
}
|
||
|
|
_ => serde_json::json!({}),
|
||
|
|
};
|
||
|
|
|
||
|
|
// First restore the preview, then apply permanently
|
||
|
|
self.documents[self.active_doc].engine.filter_preview_restore();
|
||
|
|
|
||
|
|
let filter_id = match &self.active_dialog {
|
||
|
|
ActiveDialog::BrightnessContrast => "brightness_contrast",
|
||
|
|
ActiveDialog::HueSaturation => "hue_saturation",
|
||
|
|
_ => "",
|
||
|
|
};
|
||
|
|
|
||
|
|
if !filter_id.is_empty() {
|
||
|
|
self.documents[self.active_doc].engine.apply_filter(filter_id, params);
|
||
|
|
}
|
||
|
|
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
self.active_dialog = ActiveDialog::None;
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Selection Dialog ──────────────────────────
|
||
|
|
Message::SelectionOpValue(v) => {
|
||
|
|
self.dialog_selection_value = v;
|
||
|
|
}
|
||
|
|
Message::SelectionOpApply => {
|
||
|
|
let v = self.dialog_selection_value as u32;
|
||
|
|
match &self.active_dialog {
|
||
|
|
ActiveDialog::SelectionOp(op) => {
|
||
|
|
match *op {
|
||
|
|
"Grow" => self.documents[self.active_doc].engine.selection_grow(v),
|
||
|
|
"Shrink" => self.documents[self.active_doc].engine.selection_shrink(v),
|
||
|
|
"Feather" => self.documents[self.active_doc].engine.selection_feather(v as f32),
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
self.active_dialog = ActiveDialog::None;
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Selection tools ───────────────────────────
|
||
|
|
Message::SelectionDragStart(x, y) => {
|
||
|
|
self.documents[self.active_doc].selection_rect = Some((x, y, x, y));
|
||
|
|
}
|
||
|
|
Message::SelectionDragMove(x, y) => {
|
||
|
|
if let Some((x0, y0, _, _)) = self.documents[self.active_doc].selection_rect {
|
||
|
|
self.documents[self.active_doc].selection_rect = Some((x0, y0, x, y));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Message::SelectionDragEnd => {
|
||
|
|
if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect.take() {
|
||
|
|
let sx = x0.min(x1) as u32;
|
||
|
|
let sy = y0.min(y1) as u32;
|
||
|
|
let ex = x0.max(x1) as u32;
|
||
|
|
let ey = y0.max(y1) as u32;
|
||
|
|
if ex > sx && ey > sy {
|
||
|
|
self.documents[self.active_doc].engine.create_selection_rect(sx, sy, ex, ey);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Vector tools ──────────────────────────────
|
||
|
|
Message::VectorDrawStart(x, y) => {
|
||
|
|
self.documents[self.active_doc].vector_draw = Some(((x, y), (x, y)));
|
||
|
|
}
|
||
|
|
Message::VectorDrawMove(x, y) => {
|
||
|
|
if let Some((start, _)) = self.documents[self.active_doc].vector_draw {
|
||
|
|
self.documents[self.active_doc].vector_draw = Some((start, (x, y)));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Message::VectorDrawEnd => {
|
||
|
|
if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].vector_draw.take() {
|
||
|
|
let engine = &mut self.documents[self.active_doc].engine;
|
||
|
|
let color = self.fg_color;
|
||
|
|
|
||
|
|
match self.tool_state.active_tool {
|
||
|
|
Tool::VectorRect => {
|
||
|
|
let shape = hcie_engine_api::VectorShape::Rect {
|
||
|
|
x1: x0.min(x1),
|
||
|
|
y1: y0.min(y1),
|
||
|
|
x2: x0.max(x1),
|
||
|
|
y2: y0.max(y1),
|
||
|
|
fill: true,
|
||
|
|
fill_color: color,
|
||
|
|
stroke: 2.0,
|
||
|
|
color,
|
||
|
|
radius: 0.0,
|
||
|
|
angle: 0.0,
|
||
|
|
opacity: 1.0,
|
||
|
|
hardness: 0.5,
|
||
|
|
};
|
||
|
|
engine.add_vector_shape(shape);
|
||
|
|
}
|
||
|
|
Tool::VectorCircle => {
|
||
|
|
let shape = hcie_engine_api::VectorShape::Circle {
|
||
|
|
x1: x0.min(x1),
|
||
|
|
y1: y0.min(y1),
|
||
|
|
x2: x0.max(x1),
|
||
|
|
y2: y0.max(y1),
|
||
|
|
fill: true,
|
||
|
|
fill_color: color,
|
||
|
|
stroke: 2.0,
|
||
|
|
color,
|
||
|
|
angle: 0.0,
|
||
|
|
opacity: 1.0,
|
||
|
|
hardness: 0.5,
|
||
|
|
};
|
||
|
|
engine.add_vector_shape(shape);
|
||
|
|
}
|
||
|
|
Tool::VectorLine => {
|
||
|
|
let shape = hcie_engine_api::VectorShape::Line {
|
||
|
|
x1: x0,
|
||
|
|
y1: y0,
|
||
|
|
x2: x1,
|
||
|
|
y2: y1,
|
||
|
|
stroke: 2.0,
|
||
|
|
color,
|
||
|
|
angle: 0.0,
|
||
|
|
cap_start: hcie_engine_api::LineCap::Round,
|
||
|
|
cap_end: hcie_engine_api::LineCap::Round,
|
||
|
|
opacity: 1.0,
|
||
|
|
hardness: 0.5,
|
||
|
|
};
|
||
|
|
engine.add_vector_shape(shape);
|
||
|
|
}
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── AI Chat ───────────────────────────────────
|
||
|
|
Message::AiChatInput(_text) => {
|
||
|
|
// Handled by the chat panel directly
|
||
|
|
}
|
||
|
|
Message::AiChatSend => {
|
||
|
|
// Placeholder: would send to LLM
|
||
|
|
log::info!("AI chat send not yet implemented");
|
||
|
|
}
|
||
|
|
Message::AiChatClear => {
|
||
|
|
// Handled by the chat panel directly
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Clipboard ─────────────────────────────────
|
||
|
|
Message::CopyImage => {
|
||
|
|
let doc = &self.documents[self.active_doc];
|
||
|
|
let w = doc.engine.canvas_width();
|
||
|
|
let h = doc.engine.canvas_height();
|
||
|
|
let pixels = doc.composite_buffer.clone();
|
||
|
|
return Task::perform(
|
||
|
|
async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) },
|
||
|
|
Message::ImageCopied,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Message::PasteImage => {
|
||
|
|
return Task::perform(
|
||
|
|
async { crate::io::clipboard::paste_image_from_clipboard() },
|
||
|
|
Message::ImagePasted,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Message::CopyText(text) => {
|
||
|
|
let text = text.clone();
|
||
|
|
return Task::perform(
|
||
|
|
async move { crate::io::clipboard::copy_text_to_clipboard(&text) },
|
||
|
|
|_| Message::NoOp,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Message::PasteText => {
|
||
|
|
return Task::perform(
|
||
|
|
async { crate::io::clipboard::paste_text_from_clipboard() },
|
||
|
|
Message::ClipboardResult,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Message::ClipboardResult(Ok(Some(text))) => {
|
||
|
|
log::info!("Pasted text: {}", &text[..text.len().min(50)]);
|
||
|
|
}
|
||
|
|
Message::ClipboardResult(Ok(None)) => {
|
||
|
|
log::info!("Clipboard is empty");
|
||
|
|
}
|
||
|
|
Message::ClipboardResult(Err(e)) => {
|
||
|
|
log::error!("Clipboard error: {}", e);
|
||
|
|
}
|
||
|
|
Message::ImageCopied(Ok(())) => {
|
||
|
|
log::info!("Image copied to clipboard");
|
||
|
|
}
|
||
|
|
Message::ImageCopied(Err(e)) => {
|
||
|
|
log::error!("Failed to copy image: {}", e);
|
||
|
|
}
|
||
|
|
Message::ImagePasted(Ok(Some((pixels, w, h)))) => {
|
||
|
|
log::info!("Pasted image: {}x{}", w, h);
|
||
|
|
let engine = &mut self.documents[self.active_doc].engine;
|
||
|
|
engine.create_layer_from_data("Pasted", pixels, w, h);
|
||
|
|
self.documents[self.active_doc].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Message::ImagePasted(Ok(None)) => {
|
||
|
|
log::info!("No image in clipboard");
|
||
|
|
}
|
||
|
|
Message::ImagePasted(Err(e)) => {
|
||
|
|
log::error!("Failed to paste image: {}", e);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── File I/O (with rfd) ───────────────────────
|
||
|
|
Message::OpenFileRfd => {
|
||
|
|
return Task::perform(
|
||
|
|
async {
|
||
|
|
rfd::AsyncFileDialog::new()
|
||
|
|
.set_title("Open Image")
|
||
|
|
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "psd", "kra", "hcie"])
|
||
|
|
.pick_file()
|
||
|
|
.await
|
||
|
|
.map(|handle| handle.path().to_path_buf())
|
||
|
|
},
|
||
|
|
Message::FileDialogClosed,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Message::SaveFileAs => {
|
||
|
|
return Task::perform(
|
||
|
|
async {
|
||
|
|
rfd::AsyncFileDialog::new()
|
||
|
|
.set_title("Save As")
|
||
|
|
.add_filter("PNG", &["png"])
|
||
|
|
.add_filter("JPEG", &["jpg", "jpeg"])
|
||
|
|
.add_filter("WebP", &["webp"])
|
||
|
|
.save_file()
|
||
|
|
.await
|
||
|
|
.map(|handle| handle.path().to_path_buf())
|
||
|
|
},
|
||
|
|
Message::FileDialogClosed,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Message::FileDialogClosed(Some(path)) => {
|
||
|
|
let path_str = path.to_string_lossy().to_string();
|
||
|
|
let ext = path.extension()
|
||
|
|
.and_then(|e| e.to_str())
|
||
|
|
.unwrap_or("png")
|
||
|
|
.to_lowercase();
|
||
|
|
|
||
|
|
if self.active_dialog == ActiveDialog::None {
|
||
|
|
// Open file
|
||
|
|
let engine = &mut self.documents[self.active_doc].engine;
|
||
|
|
match engine.open_image(&path_str) {
|
||
|
|
Ok(()) => {
|
||
|
|
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].composite_dirty = true;
|
||
|
|
self.refresh_composite_if_needed();
|
||
|
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||
|
|
}
|
||
|
|
Err(e) => log::error!("Failed to open: {}", e),
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Save file
|
||
|
|
let engine = &self.documents[self.active_doc].engine;
|
||
|
|
match engine.save_as(&path_str, &ext) {
|
||
|
|
Ok(()) => log::info!("Saved to {}", path_str),
|
||
|
|
Err(e) => log::error!("Failed to save: {}", e),
|
||
|
|
}
|
||
|
|
self.active_dialog = ActiveDialog::None;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Message::FileDialogClosed(None) => {
|
||
|
|
// User cancelled
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Dock Layout ───────────────────────────────
|
||
|
|
Message::PaneClicked(pane) => {
|
||
|
|
self.dock.focus(pane);
|
||
|
|
}
|
||
|
|
Message::PaneDragged(drag_event) => {
|
||
|
|
if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = drag_event {
|
||
|
|
if let iced::widget::pane_grid::Target::Pane(target_pane, _) = target {
|
||
|
|
self.dock.pane_grid.swap(pane, target_pane);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Message::PaneResized(resize_event) => {
|
||
|
|
self.dock.pane_grid.resize(resize_event.split, resize_event.ratio);
|
||
|
|
}
|
||
|
|
Message::PaneClose(pane) => {
|
||
|
|
// Don't close canvas panes
|
||
|
|
if let Some(PaneType::Canvas) = self.dock.pane_type(pane) {
|
||
|
|
return Task::none();
|
||
|
|
}
|
||
|
|
self.dock.close_pane(pane);
|
||
|
|
}
|
||
|
|
Message::PaneMaximize(pane) => {
|
||
|
|
self.dock.toggle_maximize(pane);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── File I/O ─────────────────────────────────
|
||
|
|
Message::NewDocument(w, h) => {
|
||
|
|
let mut engine = Engine::new(w, h);
|
||
|
|
engine.pre_tile_all_layers();
|
||
|
|
let composite_buffer = engine.get_composite_pixels();
|
||
|
|
let cached_layers = engine.layer_infos();
|
||
|
|
let history_len = engine.history_len();
|
||
|
|
let cached_history: Vec<(usize, String)> = (0..history_len)
|
||
|
|
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
|
||
|
|
.collect();
|
||
|
|
let history_current = engine.history_current();
|
||
|
|
self.documents.push(IcedDocument {
|
||
|
|
engine,
|
||
|
|
composite_buffer,
|
||
|
|
composite_dirty: false,
|
||
|
|
name: "Untitled".to_string(),
|
||
|
|
source_path: None,
|
||
|
|
modified: false,
|
||
|
|
zoom: 1.0,
|
||
|
|
pan_offset: Vector::ZERO,
|
||
|
|
cached_layers,
|
||
|
|
cached_history,
|
||
|
|
history_current,
|
||
|
|
selection_rect: None,
|
||
|
|
vector_draw: None,
|
||
|
|
});
|
||
|
|
self.active_doc = self.documents.len() - 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::DocSwitch(idx) => {
|
||
|
|
if idx < self.documents.len() {
|
||
|
|
self.active_doc = idx;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::OpenFile => {
|
||
|
|
return Task::perform(
|
||
|
|
async {
|
||
|
|
rfd::AsyncFileDialog::new()
|
||
|
|
.set_title("Open Image")
|
||
|
|
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "psd", "kra", "hcie"])
|
||
|
|
.pick_file()
|
||
|
|
.await
|
||
|
|
.map(|handle| handle.path().to_path_buf())
|
||
|
|
},
|
||
|
|
Message::FileDialogClosed,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::FileOpened(Ok(path)) => {
|
||
|
|
log::info!("File opened: {:?}", path);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::FileOpened(Err(e)) => {
|
||
|
|
log::error!("Failed to open file: {}", e);
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::SaveFile => {
|
||
|
|
return Task::perform(
|
||
|
|
async {
|
||
|
|
rfd::AsyncFileDialog::new()
|
||
|
|
.set_title("Save As")
|
||
|
|
.add_filter("PNG", &["png"])
|
||
|
|
.add_filter("JPEG", &["jpg", "jpeg"])
|
||
|
|
.add_filter("WebP", &["webp"])
|
||
|
|
.save_file()
|
||
|
|
.await
|
||
|
|
.map(|handle| handle.path().to_path_buf())
|
||
|
|
},
|
||
|
|
Message::FileDialogClosed,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Menu ─────────────────────────────────────
|
||
|
|
Message::MenuOpen(idx) => {
|
||
|
|
if self.active_menu == Some(idx) {
|
||
|
|
self.active_menu = None;
|
||
|
|
} else {
|
||
|
|
self.active_menu = Some(idx);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Message::MenuClose => {
|
||
|
|
if self.active_menu.is_some() {
|
||
|
|
self.active_menu = None;
|
||
|
|
} else if self.active_dialog != ActiveDialog::None {
|
||
|
|
self.active_dialog = ActiveDialog::None;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)),
|
||
|
|
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd),
|
||
|
|
(0, 2) => return Task::perform(async {}, |_| Message::SaveFileAs),
|
||
|
|
(0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs),
|
||
|
|
// Edit menu
|
||
|
|
(1, 0) => return Task::perform(async {}, |_| Message::Undo),
|
||
|
|
(1, 1) => return Task::perform(async {}, |_| Message::Redo),
|
||
|
|
(1, 4) => return Task::perform(async {}, |_| Message::PasteImage),
|
||
|
|
// Tools menu
|
||
|
|
(2, idx) => {
|
||
|
|
let tools = [
|
||
|
|
Tool::Move, Tool::VectorSelect,
|
||
|
|
Tool::Select, Tool::Lasso, Tool::PolygonSelect, Tool::MagicWand,
|
||
|
|
Tool::Crop, Tool::Eyedropper,
|
||
|
|
Tool::Brush, Tool::Pen, Tool::Spray, Tool::Eraser,
|
||
|
|
Tool::FloodFill, Tool::Gradient, Tool::Text,
|
||
|
|
Tool::VectorLine, Tool::VectorRect, Tool::VectorCircle,
|
||
|
|
];
|
||
|
|
if idx < tools.len() {
|
||
|
|
self.tool_state.active_tool = tools[idx];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Layer menu
|
||
|
|
(4, 0) => return Task::perform(async {}, |_| Message::LayerAdd),
|
||
|
|
(4, 3) => return Task::perform(async {}, |_| Message::LayerMergeDown),
|
||
|
|
(4, 4) => return Task::perform(async {}, |_| Message::LayerFlatten),
|
||
|
|
// Filter menu
|
||
|
|
(5, 1) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::BoxBlur)),
|
||
|
|
(5, 2) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::GaussianBlur)),
|
||
|
|
(5, 3) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::MotionBlur)),
|
||
|
|
(5, 5) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::UnsharpMask)),
|
||
|
|
(5, 7) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Mosaic)),
|
||
|
|
(5, 8) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Crystallize)),
|
||
|
|
(5, 10) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Pinch)),
|
||
|
|
(5, 11) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Twirl)),
|
||
|
|
(5, 13) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::OilPaint)),
|
||
|
|
// View menu
|
||
|
|
(7, 0) => return Task::perform(async {}, |_| Message::CanvasZoom { delta: 1.0 }),
|
||
|
|
(7, 1) => return Task::perform(async {}, |_| Message::CanvasZoom { delta: -1.0 }),
|
||
|
|
(7, 2) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)),
|
||
|
|
(7, 4) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)),
|
||
|
|
// Escape closes menu
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Toolbox ──────────────────────────────────
|
||
|
|
Message::ToggleToolbox => {
|
||
|
|
self.toolbox_two_column = !self.toolbox_two_column;
|
||
|
|
}
|
||
|
|
|
||
|
|
Message::NoOp => {}
|
||
|
|
}
|
||
|
|
|
||
|
|
Task::none()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── View ────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Build the UI element tree.
|
||
|
|
///
|
||
|
|
/// Uses the dock system for the main layout. The title bar, menu bar,
|
||
|
|
/// dock grid, and status bar surround the content. Dialogs overlay everything.
|
||
|
|
pub fn view(&self) -> Element<'_, Message> {
|
||
|
|
let doc = &self.documents[self.active_doc];
|
||
|
|
let colors = self.theme_state.colors();
|
||
|
|
|
||
|
|
// Title bar
|
||
|
|
let title_bar = panels::title_bar::view(
|
||
|
|
&doc.name,
|
||
|
|
doc.zoom,
|
||
|
|
doc.engine.canvas_width(),
|
||
|
|
doc.engine.canvas_height(),
|
||
|
|
colors,
|
||
|
|
);
|
||
|
|
|
||
|
|
// Menu bar
|
||
|
|
let menu_bar = panels::menus::view(&self.tool_state.active_tool, self.active_menu);
|
||
|
|
|
||
|
|
// Dock grid (replaces hardcoded layout)
|
||
|
|
let dock_content = crate::dock::view::dock_view(&self.dock, self);
|
||
|
|
|
||
|
|
// Status bar
|
||
|
|
let status_bar = panels::status_bar::view(
|
||
|
|
&self.tool_state.active_tool,
|
||
|
|
self.canvas_cursor_pos,
|
||
|
|
doc.zoom,
|
||
|
|
doc.engine.canvas_width(),
|
||
|
|
doc.engine.canvas_height(),
|
||
|
|
colors,
|
||
|
|
);
|
||
|
|
|
||
|
|
let content = column![
|
||
|
|
title_bar,
|
||
|
|
menu_bar,
|
||
|
|
iced::widget::horizontal_rule(1),
|
||
|
|
dock_content,
|
||
|
|
iced::widget::horizontal_rule(1),
|
||
|
|
status_bar,
|
||
|
|
]
|
||
|
|
.width(Length::Fill)
|
||
|
|
.height(Length::Fill);
|
||
|
|
|
||
|
|
// Dialog overlay
|
||
|
|
let dialog_overlay: Element<'_, Message> = match &self.active_dialog {
|
||
|
|
ActiveDialog::None => text("").into(),
|
||
|
|
ActiveDialog::NewImage => dialogs::new_image::view(
|
||
|
|
&self.dialog_new_name,
|
||
|
|
self.dialog_new_width,
|
||
|
|
self.dialog_new_height,
|
||
|
|
self.dialog_new_transparent,
|
||
|
|
),
|
||
|
|
ActiveDialog::BrightnessContrast => dialogs::adjustments::brightness_contrast_view(
|
||
|
|
self.dialog_brightness,
|
||
|
|
self.dialog_contrast,
|
||
|
|
),
|
||
|
|
ActiveDialog::HueSaturation => dialogs::adjustments::hsl_view(
|
||
|
|
self.dialog_hue,
|
||
|
|
self.dialog_saturation,
|
||
|
|
self.dialog_lightness,
|
||
|
|
),
|
||
|
|
ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name),
|
||
|
|
ActiveDialog::SelectionOp(op) => dialogs::confirm::selection_op_view(
|
||
|
|
op,
|
||
|
|
self.dialog_selection_value,
|
||
|
|
1.0,
|
||
|
|
100.0,
|
||
|
|
),
|
||
|
|
};
|
||
|
|
|
||
|
|
// Stack main content with dialog overlay
|
||
|
|
let stacked = container(content)
|
||
|
|
.width(Length::Fill)
|
||
|
|
.height(Length::Fill)
|
||
|
|
.padding(0);
|
||
|
|
|
||
|
|
if self.active_dialog != ActiveDialog::None {
|
||
|
|
iced::widget::Stack::new()
|
||
|
|
.push(stacked)
|
||
|
|
.push(dialog_overlay)
|
||
|
|
.width(Length::Fill)
|
||
|
|
.height(Length::Fill)
|
||
|
|
.into()
|
||
|
|
} else {
|
||
|
|
stacked.into()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Theme & Subscription ────────────────────────────
|
||
|
|
|
||
|
|
/// Return the application theme.
|
||
|
|
pub fn theme(&self) -> Theme {
|
||
|
|
Theme::Dark
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Subscriptions — keyboard events for shortcuts.
|
||
|
|
///
|
||
|
|
/// Handles common keyboard shortcuts:
|
||
|
|
/// - Ctrl+Z: Undo
|
||
|
|
/// - Ctrl+Y / Ctrl+Shift+Z: Redo
|
||
|
|
/// - Ctrl+S: Save
|
||
|
|
/// - Ctrl+N: New
|
||
|
|
/// - Ctrl+O: Open
|
||
|
|
/// - Ctrl+C: Copy
|
||
|
|
/// - Ctrl+V: Paste
|
||
|
|
/// - Escape: Close dialog
|
||
|
|
pub fn subscription(&self) -> iced::Subscription<Message> {
|
||
|
|
iced::keyboard::on_key_press(|key, modifiers| {
|
||
|
|
let ctrl = modifiers.control() || modifiers.logo();
|
||
|
|
let shift = modifiers.shift();
|
||
|
|
|
||
|
|
match key {
|
||
|
|
// Ctrl+Z = Undo
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "z" && ctrl && !shift => {
|
||
|
|
Some(Message::Undo)
|
||
|
|
}
|
||
|
|
// Ctrl+Y or Ctrl+Shift+Z = Redo
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "y" && ctrl => {
|
||
|
|
Some(Message::Redo)
|
||
|
|
}
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "z" && ctrl && shift => {
|
||
|
|
Some(Message::Redo)
|
||
|
|
}
|
||
|
|
// Ctrl+S = Save
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "s" && ctrl && !shift => {
|
||
|
|
Some(Message::SaveFileAs)
|
||
|
|
}
|
||
|
|
// Ctrl+N = New
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "n" && ctrl => {
|
||
|
|
Some(Message::DialogOpen(ActiveDialog::NewImage))
|
||
|
|
}
|
||
|
|
// Ctrl+O = Open
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "o" && ctrl => {
|
||
|
|
Some(Message::OpenFileRfd)
|
||
|
|
}
|
||
|
|
// Ctrl+C = Copy
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "c" && ctrl && !shift => {
|
||
|
|
Some(Message::CopyImage)
|
||
|
|
}
|
||
|
|
// Ctrl+V = Paste
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "v" && ctrl && !shift => {
|
||
|
|
Some(Message::PasteImage)
|
||
|
|
}
|
||
|
|
// Ctrl+X = Cut
|
||
|
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => {
|
||
|
|
Some(Message::CopyImage) // TODO: cut
|
||
|
|
}
|
||
|
|
// Escape = close menu, cancel dialog, or deselect
|
||
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => {
|
||
|
|
Some(Message::MenuClose)
|
||
|
|
}
|
||
|
|
_ => None,
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|