From 07a9b35208391768df40787acad176e0cb2e73bd Mon Sep 17 00:00:00 2001 From: Halit Can Date: Fri, 10 Jul 2026 01:23:47 +0300 Subject: [PATCH] feat: Add theme and font management for egui GUI - Introduced `theme.rs` to handle visual themes, font discovery, and application of theme colors. - Implemented system font registration for Linux, macOS, and Windows. - Added `apply_theme` function to configure egui visuals based on selected theme presets. feat: Implement interactive tool state management - Created `tool_state.rs` to manage runtime tool, selection, and transform states. - Defined `ToolState` struct to encapsulate active tool, color settings, drawing states, and AI panel states. - Included functionality for managing brush presets, text editing, and selection transformations. feat: Add utility functions for image and file handling - Developed `utils.rs` with stateless helper functions for color formatting, drawing icons, and cropping images. - Implemented save dialog builders and error handling for file operations. - Added flood fill functionality for composite RGBA pixels. test: Add unit test for bitmap brush stamp functionality - Created `bitmap_brush_stamp.rs` to verify the behavior of bitmap brush stamping in the engine. - Ensured that the painted area matches expected dimensions and characteristics. --- hcie-brush-engine/src/lib.rs | 96 +- hcie-draw/src/lib.rs | 19 +- .../crates/hcie-gui-egui/src/app/ai_config.rs | 60 + .../hcie-gui-egui/src/app/document_state.rs | 130 ++ .../crates/hcie-gui-egui/src/app/mod.rs | 1282 ++--------------- .../crates/hcie-gui-egui/src/app/settings.rs | 50 + .../crates/hcie-gui-egui/src/app/theme.rs | 250 ++++ .../hcie-gui-egui/src/app/tool_state.rs | 459 ++++++ .../src/app/tools/brushes_panel.rs | 595 ++++---- .../crates/hcie-gui-egui/src/app/utils.rs | 273 ++++ .../crates/hcie-gui-egui/src/brush_import.rs | 330 ++++- .../crates/hcie-gui-egui/src/canvas/mod.rs | 79 +- hcie-engine-api/src/dynamic_loader.rs | 101 +- hcie-engine-api/tests/bitmap_brush_stamp.rs | 49 + 14 files changed, 2250 insertions(+), 1523 deletions(-) create mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/app/ai_config.rs create mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/app/document_state.rs create mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/app/settings.rs create mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/app/theme.rs create mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/app/tool_state.rs create mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/app/utils.rs create mode 100644 hcie-engine-api/tests/bitmap_brush_stamp.rs diff --git a/hcie-brush-engine/src/lib.rs b/hcie-brush-engine/src/lib.rs index 934e13c..a190bc4 100644 --- a/hcie-brush-engine/src/lib.rs +++ b/hcie-brush-engine/src/lib.rs @@ -419,11 +419,32 @@ pub fn draw_dab( opacity: f32, is_eraser: bool, mask: Option<&[u8]>, +) { + draw_dab_with_stamp(pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, None); +} + +/// Apply a single brush dab, optionally using a precomputed bitmap stamp. +/// When `stamp` is provided it overrides the procedural circle/square shape. +/// `stamp_diameter` is the width/height of the square stamp buffer. +#[allow(clippy::too_many_arguments)] +fn draw_dab_with_stamp( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + hardness: f32, + color: [u8; 4], + opacity: f32, + is_eraser: bool, + mask: Option<&[u8]>, + stamp: Option<&[u8]>, ) { let r = (size / 2.0).max(0.5); let r_sq = r * r; let hard_sq = (r * hardness).powi(2); - let soft_range_inv = 1.0 / (r_sq - hard_sq + 1e-6); + let soft_range_inv = 1.0 / (r_sq - hard_sq + 1e-6); let x_min = ((cx - r).floor() as i32).max(0).min(width as i32 - 1); let x_max = ((cx + r).ceil() as i32).max(0).min(width as i32 - 1); @@ -432,6 +453,10 @@ pub fn draw_dab( let [fr, fg, fb, _] = color; + // If a stamp is supplied, scale its local coordinates to the requested dab size. + let stamp_d = if let Some(s) = stamp { (s.len() as f32).sqrt().round() as usize } else { 0 }; + let stamp_f = stamp_d as f32; + for py in y_min..=y_max { for px in x_min..=x_max { let dx = px as f32 - cx; @@ -449,12 +474,27 @@ pub fn draw_dab( }; if mask_val == 0 { continue; } - let alpha_factor = if d_sq <= hard_sq { + let alpha_factor = if let Some(s) = stamp { + if stamp_d == 0 { + 0.0 + } else { + // Map dab footprint back into stamp coordinates. + let sx = ((dx + r) * stamp_f / size).round() as isize; + let sy = ((dy + r) * stamp_f / size).round() as isize; + if sx < 0 || sy < 0 || sx as usize >= stamp_d || sy as usize >= stamp_d { + 0.0 + } else { + s[(sy as usize) * stamp_d + (sx as usize)] as f32 / 255.0 + } + } + } else if d_sq <= hard_sq { 1.0 } else { (1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0) }; + if alpha_factor <= 0.0 { continue; } + let dab_alpha = alpha_factor * opacity * (color[3] as f32 / 255.0) * (mask_val as f32 / 255.0); let brush_alpha = (dab_alpha * 255.0).round() as u8; let i = idx * 4; @@ -586,19 +626,41 @@ pub fn draw_dab_capped( opacity: f32, is_eraser: bool, mask: Option<&[u8]>, - mut stroke_mask: Option<&mut [u8]>, + stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, max_stroke_opacity: f32, +) { + draw_dab_capped_with_stamp(pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, stroke_mask, bg_pixels, max_stroke_opacity, None); +} + +/// `draw_dab_capped` variant that uses a pre-generated bitmap stamp for the dab shape. +#[allow(clippy::too_many_arguments)] +pub fn draw_dab_capped_with_stamp( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + hardness: f32, + color: [u8; 4], + opacity: f32, + is_eraser: bool, + mask: Option<&[u8]>, + mut stroke_mask: Option<&mut [u8]>, + bg_pixels: Option<&[u8]>, + max_stroke_opacity: f32, + stamp: Option<&[u8]>, ) { if stroke_mask.is_none() || bg_pixels.is_none() { - draw_dab(pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask); + draw_dab_with_stamp(pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, stamp); return; } let r = (size / 2.0).max(0.5); let r_sq = r * r; let hard_sq = (r * hardness).powi(2); - let soft_range_inv = 1.0 / (r_sq - hard_sq + 1e-6); + let soft_range_inv = 1.0 / (r_sq - hard_sq + 1e-6); let x_min = ((cx - r).floor() as i32).max(0).min(width as i32 - 1); let x_max = ((cx + r).ceil() as i32).max(0).min(width as i32 - 1); @@ -609,6 +671,10 @@ pub fn draw_dab_capped( let stroke_mask_ref = stroke_mask.as_mut().unwrap(); let bg = bg_pixels.unwrap(); + // Bitmap stamp support: if a stamp is provided, use it as the per-pixel alpha mask. + let stamp_d = if let Some(s) = stamp { (s.len() as f32).sqrt().round() as usize } else { 0 }; + let stamp_f = stamp_d as f32; + for py in y_min..=y_max { for px in x_min..=x_max { let dx = px as f32 - cx; @@ -626,17 +692,31 @@ pub fn draw_dab_capped( }; if mask_val == 0 { continue; } - let alpha_factor = if d_sq <= hard_sq { + let alpha_factor = if let Some(s) = stamp { + if stamp_d == 0 { + 0.0 + } else { + let sx = ((dx + r) * stamp_f / size).round() as isize; + let sy = ((dy + r) * stamp_f / size).round() as isize; + if sx < 0 || sy < 0 || sx as usize >= stamp_d || sy as usize >= stamp_d { + 0.0 + } else { + s[(sy as usize) * stamp_d + (sx as usize)] as f32 / 255.0 + } + } + } else if d_sq <= hard_sq { 1.0 } else { (1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0) }; + if alpha_factor <= 0.0 { continue; } + let dab_alpha = alpha_factor * opacity * (color[3] as f32 / 255.0) * (mask_val as f32 / 255.0); let i = idx * 4; let current_stroke_a = stroke_mask_ref[idx] as f32 / 255.0; - + // Blend dab alpha into the accumulated stroke mask (capped at max_stroke_opacity) let new_stroke_a = (current_stroke_a + dab_alpha * (1.0 - current_stroke_a)).min(max_stroke_opacity); stroke_mask_ref[idx] = (new_stroke_a * 255.0).round() as u8; @@ -649,7 +729,7 @@ pub fn draw_dab_capped( let bg_pixel = [bg[i], bg[i+1], bg[i+2], bg[i+3]]; let brush_stamp = [fr, fg, fb, (new_stroke_a * 255.0).round() as u8]; let out = alpha_blend(bg_pixel, brush_stamp); - + pixels[i] = out[0]; pixels[i+1] = out[1]; pixels[i+2] = out[2]; diff --git a/hcie-draw/src/lib.rs b/hcie-draw/src/lib.rs index 7d84623..5173533 100644 --- a/hcie-draw/src/lib.rs +++ b/hcie-draw/src/lib.rs @@ -64,6 +64,19 @@ pub fn draw_brush_stroke( None }; + // Pre-generate a bitmap stamp once for the whole stroke. Bitmap brushes + // ignore the procedural shape and use the imported/sampled alpha mask. + let bitmap_stamp = if tip.style == BrushStyle::Bitmap + && !tip.bitmap_pixels.is_empty() + && tip.bitmap_w > 0 + && tip.bitmap_h > 0 + { + Some(hcie_brush_engine::generate_brush_stamp(tip)) + } else { + None + }; + let stamp_ref = bitmap_stamp.as_deref(); + let mut last_p = points[0]; let (jx0, jy0) = jitter_offset(tip.jitter_amount, tip.size); let first_x = last_p.0 + jx0; @@ -79,10 +92,11 @@ pub fn draw_brush_stroke( first_color[0], first_color[1], first_color[2], (first_color[3] as f32 * first_opacity).round() as u8, ]; - hcie_brush_engine::draw_dab_capped( + hcie_brush_engine::draw_dab_capped_with_stamp( &mut layer.pixels, layer.width, layer.height, first_x, first_y, first_size, tip.hardness, first_px_color, 1.0, is_eraser, mask, stroke_mask.as_deref_mut(), bg_pixels, tip.opacity, + stamp_ref, ); for &p in &points[1..] { @@ -116,10 +130,11 @@ pub fn draw_brush_stroke( dab_color[0], dab_color[1], dab_color[2], (dab_color[3] as f32 * pressure_opacity).round() as u8, ]; - hcie_brush_engine::draw_dab_capped( + hcie_brush_engine::draw_dab_capped_with_stamp( &mut layer.pixels, layer.width, layer.height, final_x, final_y, effective_size, tip.hardness, px_color, 1.0, is_eraser, mask, stroke_mask.as_deref_mut(), bg_pixels, tip.opacity, + stamp_ref, ); } diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/ai_config.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/ai_config.rs new file mode 100644 index 0000000..fde6d5f --- /dev/null +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/ai_config.rs @@ -0,0 +1,60 @@ +//! AI chat and ComfyUI/vision configuration types. +//! +//! These small data-only structs describe the state used by the AI Assistant +//! panel and the optional ComfyUI/vision backend. They are grouped here so the +//! larger `ToolState` does not need to define them inline. + +/// A single chat message in the AI Assistant panel. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct AiChatMessage { + pub role: String, + pub content: String, +} + +/// URL/model pair used by the built-in AI chat client. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct AiChatConfig { + pub url: String, + pub model: String, +} + +/// Last known ComfyUI server status. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ComfyStatus { + #[default] + Unknown, + Running, + Error, +} + +/// ComfyUI server endpoint configuration. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct ComfyConfig { + pub url: String, +} + +/// Which ComfyUI workflow variant is active. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ComfyMode { + #[default] + Normal, + ObjectRemoval, +} + +/// High-level vision/AI task selected in the UI. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum VisionTask { + #[default] + Segmentation, + BackgroundRemoval, + Inpainting, + SuperResolution, +} + +/// Preferred execution backend for vision pipelines. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum VisionBackend { + #[default] + Cpu, + Gpu, +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/document_state.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/document_state.rs new file mode 100644 index 0000000..143ad0d --- /dev/null +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/document_state.rs @@ -0,0 +1,130 @@ +//! Per-document runtime state. +//! +//! Each open image owns an `AppDocument` which wraps the engine instance, GPU +//! texture handles, viewport state, and bookkeeping such as source path and +//! modification status. + +use crate::app::theme::default_font_bytes; +use crate::canvas::render::SelectionEdgeCache; +use eframe::egui; +use hcie_engine_api::Engine; + +/// Current high-level application mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AppMode { + #[default] + Editor, + Viewer, +} + +pub struct AppDocument { + pub engine: Engine, + pub composite_texture: Option, + pub layer_textures: std::collections::HashMap, + pub composite_buffer: Vec, + pub zoom: f32, + pub name: String, + pub init_snapshot_needed: bool, + pub init_snapshot_at: Option, + /// Original file path this document was loaded from, if any. + pub source_path: Option, + /// Original format extension this document was loaded as, e.g. "psd" or "kra". + pub source_format: Option, + /// Whether the document has been modified since it was last saved/loaded. + pub modified: bool, + pub history_brush_names: std::collections::HashMap, + /// Shared marching-ants edge cache for the selection overlay. + pub selection_edge_cache: SelectionEdgeCache, + /// Per-document pan offset for the canvas viewport. + /// Moved from ToolState to AppDocument so each document maintains its own + /// independent pan position, preventing cross-document interference. + pub pan_offset: egui::Vec2, + /// The last egui Context used to render/upload this document's GPU textures. + /// Used to detect when a panel is floated/docked (context change) and re-upload textures. + pub last_ctx: Option, +} + +impl AppDocument { + pub fn new(name: &str, w: u32, h: u32) -> Self { + let mut engine = Engine::new(w, h); + engine.set_modified(false); + if let Some((font_name, font_bytes)) = default_font_bytes() { + let _ = engine.load_font(&font_name, &font_bytes); + } + engine.pre_tile_all_layers(); + Self { + engine, + composite_texture: None, + layer_textures: std::collections::HashMap::new(), + composite_buffer: vec![], + zoom: 1.0, + name: name.to_string(), + init_snapshot_needed: false, + init_snapshot_at: None, + source_path: None, + source_format: None, + modified: false, + history_brush_names: std::collections::HashMap::new(), + selection_edge_cache: SelectionEdgeCache::default(), + pan_offset: egui::Vec2::ZERO, + last_ctx: None, + } + } + + pub fn record_history_description(&mut self, desc: String) { + let current = self.engine.history_current(); + if current >= 0 { + let idx = current as usize; + self.history_brush_names.retain(|&k, _| k <= idx); + self.history_brush_names.insert(idx, desc); + } + } + + pub fn save_file_name(&self) -> String { + if let Some(path) = &self.source_path { + path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("{}.png", self.name)) + } else { + format!("{}.png", self.name) + } + } + + pub fn save_file_stem(&self) -> String { + if let Some(path) = &self.source_path { + path.file_stem() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.name.clone()) + } else { + self.name.clone() + } + } + + pub fn set_source_path(&mut self, path: std::path::PathBuf, format: &str) { + self.source_path = Some(path); + self.source_format = Some(format.to_lowercase()); + self.modified = false; + } + + pub fn mark_modified(&mut self) { + self.modified = true; + } + + pub fn try_init_snapshot(&mut self) { + if !self.init_snapshot_needed { + return; + } + if let Some(at) = self.init_snapshot_at { + if std::time::Instant::now() < at { + return; + } + } + if self.engine.get_layer_count() == 0 { + self.engine.add_layer("Background"); + } + log::debug!("[try_init_snapshot] Warming up engine allocations and Rayon threadpool"); + self.engine.pre_tile_all_layers(); + self.init_snapshot_needed = false; + self.init_snapshot_at = None; + } +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs index 632da65..51ea1dd 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs @@ -6,20 +6,33 @@ //! status bar, AI state, settings persistence). //! Uses `hcie_engine_api::Engine` instead of direct `hcie_core::Document`. +pub use crate::app::ai_config::{AiChatConfig, AiChatMessage, VisionBackend, VisionTask}; +pub use crate::app::document_state::{AppDocument, AppMode}; +// Settings is named directly only by integration tests, but re-exported for API continuity. +#[allow(unused_imports)] +pub use crate::app::settings::{DockProfile, RecentFileEntry, Settings}; +pub use crate::app::theme::{ThemeColors, ThemePreset, apply_theme, default_font_bytes, setup_custom_fonts}; +pub use crate::app::tool_state::{SelectionOp, SelectionTransform, TextEditState, ToolState, TransformHandle}; +pub use crate::app::utils::{build_save_dialog, composite_flood_fill, crop_colorimage, draw_premium_pin, format_color, format_save_error_hint, save_colorimage, suggest_alternative_path}; use crate::event_bus::{AppEvent, EventBus}; use crate::plugins; use eframe::egui; -use hcie_engine_api::Engine; -use hcie_engine_api::{Tool, ToolConfigs, VectorEditHandle, VectorShape}; +use hcie_engine_api::Tool; use std::path::PathBuf; use std::sync::Arc; +pub mod ai_config; pub mod dialogs; pub mod dock; +pub mod document_state; pub mod io; pub mod panels; +pub mod settings; pub mod shell; +pub mod theme; +pub mod tool_state; pub mod tools; +pub mod utils; pub mod widgets; // ── Photopea-style tool slot definitions ────────────────────────────────────── @@ -65,960 +78,7 @@ pub const TOOL_SLOTS: &[&[Tool]] = &[ ], ]; -// ── Theme (re-exported from egui_panel_adapter for single-source-of-truth) ───── -pub use egui_panel_adapter::{ThemeColors, ThemePreset}; -pub fn setup_custom_fonts(ctx: &egui::Context) { - let mut fonts = egui::FontDefinitions::default(); - - for (name, data) in discover_system_fonts() { - fonts - .font_data - .insert(name.clone(), std::sync::Arc::new(data)); - // Override the default proportional font family so UI uses OS font - fonts - .families - .entry(egui::FontFamily::Proportional) - .or_default() - .insert(0, name); - } - - ctx.set_fonts(fonts); -} - -/// Load the first available system font and return its (file_name, bytes). -/// This is shared between egui font setup and engine text-renderer registration. -#[cfg(target_os = "linux")] -fn discover_system_fonts() -> Vec<(String, egui::FontData)> { - let mut res = Vec::new(); - let paths = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/truetype/freefont/FreeSans.ttf", - ]; - for path in &paths { - if let Ok(bytes) = std::fs::read(path) { - let name = path.rsplit('/').next().unwrap_or("system").to_string(); - res.push((name, egui::FontData::from_owned(bytes))); - break; - } - } - res -} - -#[cfg(target_os = "macos")] -fn discover_system_fonts() -> Vec<(String, egui::FontData)> { - let mut res = Vec::new(); - let paths = [ - "/System/Library/Fonts/Helvetica.ttc", - "/System/Library/Fonts/HelveticaNeue.ttc", - "/Library/Fonts/Arial.ttf", - ]; - for path in &paths { - if let Ok(bytes) = std::fs::read(path) { - let name = path.rsplit('/').next().unwrap_or("system").to_string(); - res.push((name, egui::FontData::from_owned(bytes))); - break; - } - } - res -} - -#[cfg(target_os = "windows")] -fn discover_system_fonts() -> Vec<(String, egui::FontData)> { - let mut res = Vec::new(); - let paths = [ - "C:\\Windows\\Fonts\\arial.ttf", - "C:\\Windows\\Fonts\\segoeui.ttf", - "C:\\Windows\\Fonts\\calibri.ttf", - ]; - for path in &paths { - if let Ok(bytes) = std::fs::read(path) { - let name = path.rsplit('\\').next().unwrap_or("system").to_string(); - res.push((name, egui::FontData::from_owned(bytes))); - break; - } - } - res -} - -/// Returns the raw bytes and a normalized name for the first available system font. -/// Used to seed the engine's `TextRenderer` with a default font on startup. -pub fn default_font_bytes() -> Option<(String, Vec)> { - let paths: &[(&str, &str)] = &[ - #[cfg(target_os = "linux")] - ( - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "DejaVuSans.ttf", - ), - #[cfg(target_os = "linux")] - ( - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "LiberationSans-Regular.ttf", - ), - #[cfg(target_os = "linux")] - ( - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - "NotoSans-Regular.ttf", - ), - #[cfg(target_os = "linux")] - ( - "/usr/share/fonts/truetype/freefont/FreeSans.ttf", - "FreeSans.ttf", - ), - #[cfg(target_os = "macos")] - ("/System/Library/Fonts/Helvetica.ttc", "Helvetica.ttc"), - #[cfg(target_os = "macos")] - ( - "/System/Library/Fonts/HelveticaNeue.ttc", - "HelveticaNeue.ttc", - ), - #[cfg(target_os = "macos")] - ("/Library/Fonts/Arial.ttf", "Arial.ttf"), - #[cfg(target_os = "windows")] - ("C:\\Windows\\Fonts\\arial.ttf", "arial.ttf"), - #[cfg(target_os = "windows")] - ("C:\\Windows\\Fonts\\segoeui.ttf", "segoeui.ttf"), - #[cfg(target_os = "windows")] - ("C:\\Windows\\Fonts\\calibri.ttf", "calibri.ttf"), - ]; - for (path, name) in paths { - if let Ok(bytes) = std::fs::read(path) { - return Some(((*name).to_string(), bytes)); - } - } - None -} - -/// **Purpose:** Applies the system-wide visual theme configurations to the egui context. -/// -/// **Logic & Workflow:** -/// 1. Fetches the active `ThemeColors` palette based on the chosen preset. -/// 2. Configures default Light/Dark visuals on the egui Context. -/// 3. Propagates all color tokens directly into `style.visuals` structures so that -/// non-interactive panel elements, idle buttons, hover highlights, active states, -/// scrollbars, text selections, and dropdowns inherit the theme. -/// 4. Harmonizes component padding, margins, and roundings for a clean desktop design. -/// -/// **Arguments:** -/// * `ctx`: The reference to the active egui Context. -/// * `theme`: The selected ThemePreset enum. -/// Flood fill on composite RGBA pixels — works across all layers. -/// Returns a `w*h` byte mask where 255 = selected, 0 = not selected. -fn composite_flood_fill( - pixels: &[u8], - w: u32, - h: u32, - sx: u32, - sy: u32, - tolerance: i32, -) -> Vec { - let (w, h) = (w as usize, h as usize); - let mut mask = vec![0u8; w * h]; - if sx as usize >= w || sy as usize >= h || pixels.len() < w * h * 4 { - return mask; - } - - let seed = (sy as usize * w + sx as usize) * 4; - let sr = pixels[seed] as i32; - let sg = pixels[seed + 1] as i32; - let sb = pixels[seed + 2] as i32; - let sa = pixels[seed + 3] as i32; - - let mut queue = std::collections::VecDeque::new(); - queue.push_back((sx as usize, sy as usize)); - mask[sy as usize * w + sx as usize] = 255; - let mut count = 0usize; - - while let Some((cx, cy)) = queue.pop_front() { - count += 1; - if count > 500_000 { - break; - } - - for (dx, dy) in [(0i32, 1i32), (0, -1), (1, 0), (-1, 0)] { - let nx = cx as i32 + dx; - let ny = cy as i32 + dy; - if nx < 0 || nx >= w as i32 || ny < 0 || ny >= h as i32 { - continue; - } - let (nx, ny) = (nx as usize, ny as usize); - let ni = ny * w + nx; - if mask[ni] != 0 { - continue; - } - - let pi = ni * 4; - let diff = (pixels[pi] as i32 - sr).abs() - + (pixels[pi + 1] as i32 - sg).abs() - + (pixels[pi + 2] as i32 - sb).abs() - + (pixels[pi + 3] as i32 - sa).abs(); - - if diff <= tolerance { - mask[ni] = 255; - queue.push_back((nx, ny)); - } - } - } - mask -} - -pub fn apply_theme(ctx: &egui::Context, theme: ThemePreset) { - let colors = ThemeColors::get(theme); - let is_light = colors.is_light; - ctx.set_visuals(if is_light { - egui::Visuals::light() - } else { - egui::Visuals::dark() - }); - ctx.style_mut(|style| { - // ── Rounded corners (consistent 4px everywhere) ── - let r4 = egui::CornerRadius::same(4); - style.visuals.widgets.noninteractive.corner_radius = r4; - style.visuals.widgets.inactive.corner_radius = r4; - style.visuals.widgets.hovered.corner_radius = r4; - style.visuals.widgets.active.corner_radius = r4; - style.visuals.widgets.open.corner_radius = r4; - - // ── Window / popup rounding ── - style.visuals.window_corner_radius = egui::CornerRadius::same(6); - style.visuals.menu_corner_radius = egui::CornerRadius::same(6); - - // ── Core surface fills (3-tier elevation) ── - style.visuals.window_fill = colors.bg_elevated; - style.visuals.panel_fill = colors.bg_panel; - style.visuals.window_stroke = egui::Stroke::new(1.0, colors.border_high); - style.visuals.extreme_bg_color = colors.bg_recessed; - style.visuals.faint_bg_color = colors.bg_hover.gamma_multiply(0.4); - - // ── Noninteractive widgets (labels, static elements) ── - style.visuals.widgets.noninteractive.bg_fill = colors.bg_panel; - style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, colors.border_low); - style.visuals.widgets.noninteractive.fg_stroke = - egui::Stroke::new(1.0, colors.text_secondary); - - // ── Inactive widgets (idle buttons, slider tracks, checkboxes) ── - style.visuals.widgets.inactive.bg_fill = colors.bg_dark_depth; - style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, colors.border_high); - style.visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, colors.text_primary); - - // ── Hover states ── - style.visuals.widgets.hovered.bg_fill = colors.bg_hover; - style.visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, colors.accent); - style.visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.2, colors.text_primary); - - // ── Active highlights (clicked buttons, active controls) ── - style.visuals.widgets.active.bg_fill = colors.accent; - style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, colors.accent); - style.visuals.widgets.active.fg_stroke = egui::Stroke::new(1.2, colors.text_primary); - - // ── Open menus and popups ── - style.visuals.widgets.open.bg_fill = colors.bg_elevated; - style.visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, colors.border_high); - - // ── Text selection & highlights ── - style.visuals.selection.bg_fill = colors.accent.gamma_multiply(0.3); - style.visuals.selection.stroke = egui::Stroke::new(1.0, colors.accent); - - // ── Slider trailing fill (shows value range) ── - style.visuals.slider_trailing_fill = true; - - // ── Popup shadow for depth ── - style.visuals.popup_shadow = egui::Shadow { - offset: [0, 4], - blur: 16, - spread: 0, - color: egui::Color32::from_black_alpha(if is_light { 18 } else { 60 }), - }; - - // ── Font sizes matching Qt 12px default ── - style.text_styles.insert( - egui::TextStyle::Body, - egui::FontId::proportional(12.0), - ); - style.text_styles.insert( - egui::TextStyle::Button, - egui::FontId::proportional(12.0), - ); - style.text_styles.insert( - egui::TextStyle::Small, - egui::FontId::proportional(11.0), - ); - style.text_styles.insert( - egui::TextStyle::Heading, - egui::FontId::proportional(13.5), - ); - style.text_styles.insert( - egui::TextStyle::Monospace, - egui::FontId::proportional(11.5), - ); - - // ── Harmonized layout parameters (8px grid) ── - style.spacing.item_spacing = egui::vec2(4.0, 4.0); - style.spacing.window_margin = egui::Margin::same(6); - style.spacing.button_padding = egui::vec2(6.0, 3.0); - style.spacing.indent = 12.0; - style.spacing.slider_width = 140.0; - style.spacing.interact_size = egui::vec2(28.0, 22.0); - }); -} - -// ── Settings (minimal V2 port) ─────────────────────────────────────────────── - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct RecentFileEntry { - pub path: String, - pub name: String, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct DockProfile { - pub name: String, - pub dock_state_json: String, - pub left_col1_w: f32, - pub left_col2_w: f32, - pub right_col1_w: f32, - pub right_col2_w: f32, - pub last_window_width: f32, - pub pinned_panels: Vec, -} - -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] -pub struct Settings { - pub theme: ThemePreset, - /// When `true`, the operating system draws its own window decorations. - /// The custom title bar is always rendered regardless of this value, - /// so the out-of-box experience uses the unified custom bar. - #[serde(default = "default_show_native_decorations")] - pub show_native_decorations: bool, - pub debug_mode: bool, - #[serde(default)] - pub recent_files: Vec, - #[serde(default)] - pub dock_profiles: Vec, - /// Last directory used in Viewer mode, for cross-session persistence. - #[serde(default)] - pub viewer_last_dir: Option, -} - -fn default_show_native_decorations() -> bool { - false -} - -// ── AiChatMessage (minimal) ──────────────────────────────────────────────── - -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] -pub struct AiChatMessage { - pub role: String, - pub content: String, -} - -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] -pub struct AiChatConfig { - pub url: String, - pub model: String, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum ComfyStatus { - #[default] - Unknown, - Running, - Error, -} - -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] -pub struct ComfyConfig { - pub url: String, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum ComfyMode { - #[default] - Normal, - ObjectRemoval, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum VisionTask { - #[default] - Segmentation, - BackgroundRemoval, - Inpainting, - SuperResolution, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum VisionBackend { - #[default] - Cpu, - Gpu, -} - -// ── AppMode ────────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AppMode { - #[default] - Editor, - Viewer, -} - -// ── AppDocument ────────────────────────────────────────────────────────────── - -pub struct AppDocument { - pub engine: Engine, - pub composite_texture: Option, - pub layer_textures: std::collections::HashMap, - pub composite_buffer: Vec, - pub zoom: f32, - pub name: String, - pub init_snapshot_needed: bool, - pub init_snapshot_at: Option, - /// Original file path this document was loaded from, if any. - pub source_path: Option, - /// Original format extension this document was loaded as, e.g. "psd" or "kra". - pub source_format: Option, - /// Whether the document has been modified since it was last saved/loaded. - pub modified: bool, - pub history_brush_names: std::collections::HashMap, - /// Shared marching-ants edge cache for the selection overlay. - pub selection_edge_cache: crate::canvas::render::SelectionEdgeCache, - /// Per-document pan offset for the canvas viewport. - /// Moved from ToolState to AppDocument so each document maintains its own - /// independent pan position, preventing cross-document interference. - pub pan_offset: egui::Vec2, - /// The last egui Context used to render/upload this document's GPU textures. - /// Used to detect when a panel is floated/docked (context change) and re-upload textures. - pub last_ctx: Option, -} - -impl AppDocument { - pub fn new(name: &str, w: u32, h: u32) -> Self { - let mut engine = Engine::new(w, h); - engine.set_modified(false); - if let Some((font_name, font_bytes)) = default_font_bytes() { - let _ = engine.load_font(&font_name, &font_bytes); - } - engine.pre_tile_all_layers(); - Self { - engine, - composite_texture: None, - layer_textures: std::collections::HashMap::new(), - composite_buffer: vec![], - zoom: 1.0, - name: name.to_string(), - init_snapshot_needed: false, - init_snapshot_at: None, - source_path: None, - source_format: None, - modified: false, - history_brush_names: std::collections::HashMap::new(), - selection_edge_cache: crate::canvas::render::SelectionEdgeCache::default(), - pan_offset: egui::Vec2::ZERO, - last_ctx: None, - } - } - - pub fn record_history_description(&mut self, desc: String) { - let current = self.engine.history_current(); - if current >= 0 { - let idx = current as usize; - self.history_brush_names.retain(|&k, _| k <= idx); - self.history_brush_names.insert(idx, desc); - } - } - - pub fn save_file_name(&self) -> String { - if let Some(path) = &self.source_path { - path.file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| format!("{}.png", self.name)) - } else { - format!("{}.png", self.name) - } - } - - pub fn save_file_stem(&self) -> String { - if let Some(path) = &self.source_path { - path.file_stem() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| self.name.clone()) - } else { - self.name.clone() - } - } - - pub fn set_source_path(&mut self, path: std::path::PathBuf, format: &str) { - self.source_path = Some(path); - self.source_format = Some(format.to_lowercase()); - self.modified = false; - } - - pub fn mark_modified(&mut self) { - self.modified = true; - } - - pub fn try_init_snapshot(&mut self) { - if !self.init_snapshot_needed { - return; - } - if let Some(at) = self.init_snapshot_at { - if std::time::Instant::now() < at { - return; - } - } - if self.engine.get_layer_count() == 0 { - self.engine.add_layer("Background"); - } - log::debug!("[try_init_snapshot] Warming up engine allocations and Rayon threadpool"); - self.engine.pre_tile_all_layers(); - self.init_snapshot_needed = false; - self.init_snapshot_at = None; - } -} - -// ── ToolState ────────────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct ToolState { - pub active_tool: Tool, - pub primary_color: [u8; 4], - pub secondary_color: [u8; 4], - pub recent_colors: Vec<[u8; 4]>, - pub tool_configs: ToolConfigs, - pub current_pressure: f32, - pub pan_offset: egui::Vec2, - pub cursor_canvas_pos: Option<(u32, u32)>, - pub selection_rect: Option<[u32; 4]>, - /// VisionSelect drag rectangle in canvas coordinates, used for bbox segmentation. - pub vision_select_rect: Option<[u32; 4]>, - pub is_drawing: bool, - pub drag_start_pos: Option<(u32, u32)>, - pub last_draw_pos: Option<(u32, u32)>, - pub brush_accumulated_dist: f32, - pub lasso_points: Vec<(u32, u32)>, - pub smart_patch_points: Vec<(u32, u32)>, - pub text_edit: TextEditState, - pub selected_vector_shape: Option, - pub vector_shapes_snapshot: Option>, - pub vector_is_new_creation: bool, - pub vector_edit_handle: VectorEditHandle, - pub vector_drag_last: Option, - pub settings: Settings, - pub pinned_panels: std::collections::HashSet, - /// Set of utility panels that the user has collapsed with the custom - /// collapse chevron on the active tab. - pub collapsed_panels: std::collections::HashSet, - pub ai_panel_tab: usize, - pub ai_chat_thinking: bool, - pub is_ai_processing: bool, - pub ai_actions_busy: bool, - pub ai_chat_history: Vec, - pub ai_chat_config: AiChatConfig, - pub ai_chat_models: Vec, - pub ai_send_canvas: bool, - pub ai_vision_review: bool, - pub ai_chat_show_reasoning: bool, - pub ai_chat_max_turns: u32, - pub ai_chat_buffer: String, - pub comfy_status: ComfyStatus, - pub comfy_config: ComfyConfig, - pub comfy_mode: ComfyMode, - pub brush_presets: Vec, - pub active_brush_preset_id: String, - pub comfy_models: Vec, - pub selected_model: String, - pub vision_task: VisionTask, - pub vision_sam_model_path: String, - pub vision_birefnet_model_path: String, - pub vision_migan_model_path: String, - pub vision_backend: VisionBackend, - pub script_editor: egui_panel_script::ScriptEditor, - pub ai_script_generator: egui_panel_ai_script::AiScriptGenerator, - pub vector_shapes_expanded: bool, - pub tool_scroll_offset: f32, - pub transform: Option, - pub transform_drag_handle: TransformHandle, - /// Shared tablet/stylus state updated by winit/evdev and read each frame. - pub tablet_state: Arc>, - pub active_slot: usize, - pub slot_last_used: Vec, - pub active_popup_slot: Option, - pub popup_anchor_pos: Option, - pub popup_close_request: bool, - pub slot_press_start: Option<(usize, std::time::Instant)>, - pub popup_opened_via_drag: bool, - /// If true, the left toolbox is rendered as a two-column wide strip. - pub toolbox_two_column: bool, - /// When set, the active layer is locked to this id while a destructive - /// preview dialog (e.g. Adjustments) is open. The Layers panel refuses to - /// switch the active layer while this is set, and the app re-asserts the - /// locked layer every frame so programmatic layer changes (shortcuts, - /// canvas auto-create, etc.) cannot move the filter target to another - /// layer. Cleared when the owning dialog closes. - pub locked_active_layer: Option, - /// Whether the recent colors popup/expand section is shown. - pub show_recent_popup: bool, - /// Pending name input used by the brush editor "New Preset" action. - pub pending_brush_preset_name: Option, - /// Whether the brush editor updates the canvas brush preview live. - pub brush_editor_instant_preview: bool, - pub is_resizing_brush: bool, - pub brush_resize_start_size: f32, - pub brush_resize_start_pos: Option, -} - -impl Default for ToolState { - fn default() -> Self { - let mut pinned = std::collections::HashSet::new(); - pinned.insert("Tools".to_string()); - pinned.insert("Brushes & Tips".to_string()); - pinned.insert("Color Palette".to_string()); - pinned.insert("Layers".to_string()); - Self { - active_tool: Tool::Brush, - primary_color: [0, 0, 0, 255], - secondary_color: [255, 255, 255, 255], - recent_colors: vec![], - tool_configs: ToolConfigs::default(), - current_pressure: 1.0, - pan_offset: egui::Vec2::ZERO, - cursor_canvas_pos: None, - selection_rect: None, - vision_select_rect: None, - is_drawing: false, - drag_start_pos: None, - last_draw_pos: None, - brush_accumulated_dist: 0.0, - lasso_points: vec![], - smart_patch_points: vec![], - text_edit: TextEditState::default(), - selected_vector_shape: None, - vector_shapes_snapshot: None, - vector_is_new_creation: false, - vector_edit_handle: VectorEditHandle::None, - vector_drag_last: None, - settings: Settings::default(), - pinned_panels: pinned, - collapsed_panels: std::collections::HashSet::new(), - ai_panel_tab: 0, - ai_chat_thinking: false, - is_ai_processing: false, - ai_actions_busy: false, - ai_chat_history: vec![], - ai_chat_config: AiChatConfig { - url: "http://127.0.0.1:8188".into(), - model: "flux".into(), - }, - ai_chat_models: vec![], - ai_send_canvas: false, - ai_vision_review: false, - ai_chat_show_reasoning: false, - ai_chat_max_turns: 10, - ai_chat_buffer: String::new(), - comfy_status: ComfyStatus::Unknown, - comfy_config: ComfyConfig { - url: "http://127.0.0.1:8188".into(), - }, - comfy_mode: ComfyMode::Normal, - brush_presets: hcie_engine_api::BrushPreset::all_defaults(), - active_brush_preset_id: "basic_round".to_string(), - comfy_models: vec![], - selected_model: String::new(), - vision_task: VisionTask::default(), - vision_sam_model_path: "models/sam/MobileSAM-F16.gguf".to_string(), - vision_birefnet_model_path: "models/birefnet/BiRefNet-lite-F16.gguf".to_string(), - vision_migan_model_path: "models/migan/MIGAN-512-places2-F16.gguf".to_string(), - vision_backend: VisionBackend::default(), - script_editor: egui_panel_script::ScriptEditor::new(), - ai_script_generator: egui_panel_ai_script::AiScriptGenerator::new(), - vector_shapes_expanded: false, - tool_scroll_offset: 0.0, - transform: None, - transform_drag_handle: TransformHandle::None, - tablet_state: Arc::new(std::sync::Mutex::new( - shell::tablet_input::TabletState::new(), - )), - active_slot: TOOL_SLOTS - .iter() - .position(|s| s.contains(&Tool::Brush)) - .unwrap_or(0), - slot_last_used: { - let mut v = vec![0usize; TOOL_SLOTS.len()]; - if let Some(si) = TOOL_SLOTS.iter().position(|s| s.contains(&Tool::Brush)) { - if let Some(li) = TOOL_SLOTS[si].iter().position(|&t| t == Tool::Brush) { - v[si] = li; - } - } - v - }, - active_popup_slot: None, - popup_anchor_pos: None, - popup_close_request: false, - slot_press_start: None, - popup_opened_via_drag: false, - toolbox_two_column: false, - locked_active_layer: None, - show_recent_popup: false, - pending_brush_preset_name: None, - brush_editor_instant_preview: true, - is_resizing_brush: false, - brush_resize_start_size: 10.0, - brush_resize_start_pos: None, - } - } -} - -impl ToolState { - pub fn active_size(&self) -> f32 { - match self.active_tool { - Tool::Pen => self.tool_configs.pen.size, - Tool::Brush => self.tool_configs.brush.size, - Tool::Eraser => self.tool_configs.eraser.size, - Tool::Spray => self.tool_configs.spray.radius, - _ => 10.0, - } - } - - pub fn set_active_size(&mut self, size: f32) { - match self.active_tool { - Tool::Pen => self.tool_configs.pen.size = size, - Tool::Brush => self.tool_configs.brush.size = size, - Tool::Eraser => self.tool_configs.eraser.size = size, - Tool::Spray => self.tool_configs.spray.radius = size, - _ => {} - } - } - - pub fn active_size_mut(&mut self) -> &mut f32 { - match self.active_tool { - Tool::Pen => &mut self.tool_configs.pen.size, - Tool::Brush => &mut self.tool_configs.brush.size, - Tool::Eraser => &mut self.tool_configs.eraser.size, - Tool::Spray => &mut self.tool_configs.spray.radius, - _ => &mut self.tool_configs.pen.size, - } - } - - pub fn active_opacity(&self) -> f32 { - match self.active_tool { - Tool::Pen => self.tool_configs.pen.opacity, - Tool::Brush => self.tool_configs.brush.opacity, - Tool::Eraser => self.tool_configs.eraser.opacity, - Tool::Spray => self.tool_configs.spray.opacity, - _ => 1.0, - } - } - - pub fn set_active_opacity(&mut self, opacity: f32) { - match self.active_tool { - Tool::Pen => self.tool_configs.pen.opacity = opacity, - Tool::Brush => self.tool_configs.brush.opacity = opacity, - Tool::Eraser => self.tool_configs.eraser.opacity = opacity, - Tool::Spray => self.tool_configs.spray.opacity = opacity, - _ => {} - } - } - - pub fn brush_dirty_radius(&self) -> f32 { - self.active_size() * 0.5 + 2.0 - } - - pub fn slot_for_tool(tool: Tool) -> Option { - TOOL_SLOTS.iter().position(|slot| slot.contains(&tool)) - } - - pub fn set_active_tool_from_slot(&mut self, tool: Tool) { - if let Some(slot_idx) = Self::slot_for_tool(tool) { - self.active_slot = slot_idx; - if let Some(local_idx) = TOOL_SLOTS[slot_idx].iter().position(|&t| t == tool) { - self.slot_last_used[slot_idx] = local_idx; - } - } - self.active_tool = tool; - } -} - -// ── Selection types ───────────────────────────────────────────────────────── - -pub enum SelectionOp { - Grow(u32), - Shrink(u32), - Feather(u32), - Erode(u32), - Fade(u32), -} - -/// Live text editing state used by the inline canvas text tool. -/// -/// When the user clicks on the canvas with the Text tool, a draft text -/// layer is created and its properties are kept here. All changes in the -/// Properties panel or in the on-canvas text edit are applied to the draft -/// layer immediately so the user sees a live preview. The edits are only -/// committed when Accept is pressed; Cancel discards a newly created draft -/// layer, or reverts an existing text layer to the snapshot stored in -/// `original_text` and its companion `original_*` fields. -#[derive(Clone)] -pub struct TextEditState { - pub active: bool, - pub layer_id: u64, - pub draft_text: String, - pub draft_font: String, - pub draft_size: f32, - pub draft_color: [u8; 4], - pub draft_angle: f32, - pub draft_alignment: hcie_engine_api::TextAlignment, - pub draft_orientation: hcie_engine_api::TextOrientation, - pub canvas_x: u32, - pub canvas_y: u32, - pub accepted: bool, - pub cancelled: bool, - /// If true, the layer was created by the current inline edit session and - /// should be deleted on Cancel. If false, Cancel reverts to `original_*`. - pub is_new: bool, - /// Snapshot of the layer's text and properties taken when an existing text - /// layer enters edit mode. Used to restore the layer on Cancel. - pub original_text: String, - pub original_font: String, - pub original_size: f32, - pub original_color: [u8; 4], - pub original_angle: f32, - pub original_alignment: hcie_engine_api::TextAlignment, - pub original_orientation: hcie_engine_api::TextOrientation, -} - -impl Default for TextEditState { - fn default() -> Self { - Self { - active: false, - layer_id: 0, - draft_text: String::new(), - draft_font: "default".to_string(), - draft_size: 24.0, - draft_color: [0, 0, 0, 255], - draft_angle: 0.0, - draft_alignment: hcie_engine_api::TextAlignment::Left, - draft_orientation: hcie_engine_api::TextOrientation::Horizontal, - canvas_x: 0, - canvas_y: 0, - accepted: false, - cancelled: false, - is_new: true, - original_text: String::new(), - original_font: "default".to_string(), - original_size: 24.0, - original_color: [0, 0, 0, 255], - original_angle: 0.0, - original_alignment: hcie_engine_api::TextAlignment::Left, - original_orientation: hcie_engine_api::TextOrientation::Horizontal, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum TransformHandle { - #[default] - None, - Move, - TopLeft, - TopRight, - BottomRight, - BottomLeft, - Top, - Bottom, - Left, - Right, -} - -#[derive(Clone)] -pub struct SelectionTransform { - pub pixels: Vec, - pub width: u32, - pub height: u32, - pub pos: egui::Vec2, - pub size: egui::Vec2, -} - -impl SelectionTransform { - pub fn from_engine(engine: &hcie_engine_api::Engine, mask: &[u8], w: u32, h: u32) -> Self { - if mask.is_empty() || w == 0 || h == 0 { - return Self { - pixels: vec![], - width: 0, - height: 0, - pos: egui::Vec2::ZERO, - size: egui::Vec2::ZERO, - }; - } - let mut min_x = w; - let mut min_y = h; - let mut max_x = 0u32; - let mut max_y = 0u32; - let mut has_selection = false; - - for y in 0..h { - for x in 0..w { - if mask[(y * w + x) as usize] > 0 { - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x); - max_y = max_y.max(y); - has_selection = true; - } - } - } - - if !has_selection { - return Self { - pixels: vec![], - width: 0, - height: 0, - pos: egui::Vec2::ZERO, - size: egui::Vec2::ZERO, - }; - } - - let bw = max_x - min_x + 1; - let bh = max_y - min_y + 1; - let mut pixels = vec![0u8; (bw * bh * 4) as usize]; - - let layer_pixels = engine.get_active_layer_pixels().unwrap_or_default(); - let layer_w = engine.canvas_width() as usize; - let w_usize = w as usize; - let bw_usize = bw as usize; - let bh_usize = bh as usize; - let min_x_usize = min_x as usize; - let min_y_usize = min_y as usize; - - for y in 0..bh_usize { - for x in 0..bw_usize { - let src_x = min_x_usize + x; - let src_y = min_y_usize + y; - let mask_idx = src_y * w_usize + src_x; - if mask_idx < mask.len() && mask[mask_idx] > 0 { - let px_idx = (src_y * layer_w + src_x) * 4; - if px_idx + 3 < layer_pixels.len() { - let dst_idx = (y * bw_usize + x) * 4; - pixels[dst_idx] = layer_pixels[px_idx]; - pixels[dst_idx + 1] = layer_pixels[px_idx + 1]; - pixels[dst_idx + 2] = layer_pixels[px_idx + 2]; - pixels[dst_idx + 3] = layer_pixels[px_idx + 3]; - } - } - } - } - - Self { - pixels, - width: bw, - height: bh, - pos: egui::Vec2::new(min_x as f32, min_y as f32), - size: egui::Vec2::new(bw as f32, bh as f32), - } - } -} // ── HcieApp ────────────────────────────────────────────────────────────────── @@ -1601,14 +661,34 @@ impl HcieApp { if ext == "abr" { match std::fs::read(&path) { Ok(data) => { - let presets = crate::brush_import::import_abr(&data); - self.state.brush_presets = presets; - log::info!( - "Loaded {} ABR brush presets", - self.state.brush_presets.len() - ); + let file_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("imported") + .to_string(); + let presets = crate::brush_import::import_abr_with_prefix(&data, &file_stem); + let added = presets.len(); + for preset in presets { + if !self.state.brush_presets.iter().any(|p| p.id == preset.id) { + self.state.brush_presets.push(preset); + } + } + self.event_bus.push(AppEvent::SaveSettings); + log::info!("Added {} ABR brush preset(s) from {:?}", added, path); + rfd::MessageDialog::new() + .set_title("Brush Import") + .set_description(&format!("Added {} brush preset(s) to the Imported category.", added)) + .set_level(rfd::MessageLevel::Info) + .show(); + } + Err(e) => { + log::error!("Failed to read ABR file: {}", e); + rfd::MessageDialog::new() + .set_title("Brush Import") + .set_description(&format!("Could not read the selected file:\n{}", e)) + .set_level(rfd::MessageLevel::Error) + .show(); } - Err(e) => log::error!("Failed to read ABR file: {}", e), } return; } @@ -2736,12 +1816,68 @@ impl HcieApp { .pick_file(); if let Some(path) = path { log::info!("Brush import file selected: {:?}", path); - log::warn!("Brush import pipeline not yet wired to BrushTip::Bitmap (Phase D pending). File: {:?}", path); - rfd::MessageDialog::new() - .set_title("Brush Import") - .set_description("Brush import is not yet implemented.\n\nThis feature will be available in Phase D.") - .set_level(rfd::MessageLevel::Info) - .show(); + match std::fs::read(&path) { + Ok(data) => { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let file_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("imported") + .to_string(); + let imported = if ext == "kpp" { + crate::brush_import::import_kpp(&data, &file_stem) + .into_iter() + .collect::>() + } else { + crate::brush_import::import_abr_with_prefix( + &data, + &file_stem, + ) + }; + if imported.is_empty() { + rfd::MessageDialog::new() + .set_title("Brush Import") + .set_description(&format!( + "No usable brushes found in {}.\n\nSupported: basic ABR sampled/computed brushes and KPP PNG previews.", + path.display() + )) + .set_level(rfd::MessageLevel::Warning) + .show(); + } else { + let added = imported.len(); + for preset in imported { + if !self.state.brush_presets.iter().any(|p| p.id == preset.id) { + self.state.brush_presets.push(preset); + } + } + self.event_bus.push(AppEvent::SaveSettings); + log::info!("Added {} imported brush preset(s) from {:?}", added, path); + rfd::MessageDialog::new() + .set_title("Brush Import") + .set_description(&format!( + "Added {} brush preset(s) to the Imported category.", + added + )) + .set_level(rfd::MessageLevel::Info) + .show(); + } + } + Err(e) => { + log::error!("Failed to read brush import file {:?}: {}", path, e); + rfd::MessageDialog::new() + .set_title("Brush Import") + .set_description(&format!( + "Could not read the selected file:\n{}", + e + )) + .set_level(rfd::MessageLevel::Error) + .show(); + } + } } } AppEvent::ImportSvg(file_path) => { @@ -5450,10 +4586,6 @@ impl HcieApp { } } -fn format_color(c: &[u8; 4]) -> String { - format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) -} - // ── eframe::App impl ───────────────────────────────────────────────────────── impl eframe::App for HcieApp { @@ -5982,14 +5114,14 @@ impl eframe::App for HcieApp { if let Ok(json) = serde_json::to_string(&self.state.settings.recent_files) { storage.set_string("hcie_recent_files", json); } - let custom_presets: Vec<_> = self + let persisted_presets: Vec<_> = self .state .brush_presets .iter() - .filter(|p| p.category == "Custom") + .filter(|p| p.category == "Custom" || p.category == "Imported" || p.category == "Imported (ABR)") .cloned() .collect(); - if let Ok(json) = serde_json::to_string(&custom_presets) { + if let Ok(json) = serde_json::to_string(&persisted_presets) { storage.set_string("hcie_custom_brush_presets", json); } } @@ -5999,203 +5131,3 @@ impl eframe::App for HcieApp { } } -/// **Purpose:** Draws a pixel-perfect, highly-refined minimalist pushpin vector icon. -/// -/// **Logic & Workflow:** -/// - If `pinned` is true, renders a vertical pushpin (cap, body, ridge, needle). -/// - If `pinned` is false, renders a slanted 45-degree pushpin pointing down-left. -fn draw_premium_pin( - painter: &egui::Painter, - center: egui::Pos2, - color: egui::Color32, - pinned: bool, -) { - if pinned { - // Vertical pin (pinned state) - let cap_rect = - egui::Rect::from_center_size(center - egui::vec2(0.0, 4.0), egui::vec2(8.0, 1.5)); - painter.rect_filled(cap_rect, 0.5, color); - - let body_rect = - egui::Rect::from_center_size(center - egui::vec2(0.0, 1.0), egui::vec2(5.0, 4.5)); - painter.rect_filled(body_rect, 0.5, color); - - let ridge_rect = - egui::Rect::from_center_size(center + egui::vec2(0.0, 1.75), egui::vec2(7.0, 1.0)); - painter.rect_filled(ridge_rect, 0.0, color); - - let needle_rect = - egui::Rect::from_center_size(center + egui::vec2(0.0, 4.75), egui::vec2(1.2, 5.0)); - painter.rect_filled(needle_rect, 0.0, color); - } else { - // Slanted pin (45 degrees pointing down-left, i.e., unpinned) - let dir = egui::vec2(-1.0, 1.0).normalized(); - let ortho = egui::vec2(1.0, 1.0).normalized(); - - painter.line_segment( - [center + dir * 0.5, center + dir * 5.5], - egui::Stroke::new(1.3, color), - ); - - let ridge_c = center - dir * 0.75; - painter.line_segment( - [ridge_c - ortho * 3.0, ridge_c + ortho * 3.0], - egui::Stroke::new(1.2, color), - ); - - let body_c = ridge_c - dir * 2.0; - let p1 = body_c - ortho * 2.0 - dir * 1.5; - let p2 = body_c + ortho * 2.0 - dir * 1.5; - let p3 = body_c + ortho * 2.0 + dir * 1.5; - let p4 = body_c - ortho * 2.0 + dir * 1.5; - painter.add(egui::Shape::convex_polygon( - vec![p1, p2, p3, p4], - color, - egui::Stroke::NONE, - )); - - let cap_c = ridge_c - dir * 4.25; - painter.line_segment( - [cap_c - ortho * 3.5, cap_c + ortho * 3.5], - egui::Stroke::new(1.5, color), - ); - } -} - -/// Crop an egui ColorImage to the given screen-space rectangle (rounded to pixels). -fn crop_colorimage(img: &egui::ColorImage, rect: egui::Rect) -> Option { - let x0 = rect.min.x.max(0.0).round() as usize; - let y0 = rect.min.y.max(0.0).round() as usize; - let x1 = (rect.max.x.min(img.width() as f32)).round() as usize; - let y1 = (rect.max.y.min(img.height() as f32)).round() as usize; - let w = x1.saturating_sub(x0); - let h = y1.saturating_sub(y0); - if w == 0 || h == 0 { - return None; - } - let mut bytes = Vec::with_capacity(w * h * 4); - for y in y0..y1 { - for x in x0..x1 { - let c = img[(x, y)]; - bytes.extend_from_slice(&c.to_array()); - } - } - Some(egui::ColorImage::from_rgba_unmultiplied([w, h], &bytes)) -} - -fn save_colorimage(path: &std::path::Path, img: &egui::ColorImage) -> Result<(), String> { - let mut bytes: Vec = Vec::with_capacity(img.width() * img.height() * 4); - for c in img.pixels.iter() { - bytes.extend_from_slice(&c.to_array()); - } - image::save_buffer( - path, - &bytes, - img.width() as u32, - img.height() as u32, - image::ColorType::Rgba8, - ) - .map_err(|e| e.to_string()) -} - -/// Builds a fallback save path when the user-selected location cannot be written. -/// -/// The suggestion tries the user's home directory first, then the system temp directory, -/// preserving the original file stem and extension. This gives the user a concrete, -/// writable alternative they can select instead of silently failing. -/// Builds a Save/Export file dialog with the document's source format selected -/// by default. -/// -/// **Purpose:** Ensures the active file-type filter matches the document that -/// is being saved (e.g. KRA for a Krita file). If the format is unknown, the -/// helper still builds the dialog with HCIE Native listed first. An "All -/// Supported Types" filter is appended at the end so the user can pick any -/// supported format. -/// -/// **Arguments:** -/// * `default_format` - Lowercase extension to pre-select (`kra`, `hcie`, `png`, ...). -/// * `file_stem` - Base name for the suggested file. -/// * `is_export` - When `true`, appends `_export` to the file stem. -fn build_save_dialog(default_format: &str, file_stem: &str, is_export: bool) -> rfd::FileDialog { - let suffix = if is_export { "_export" } else { "" }; - let file_name = format!("{}{}.{}", file_stem, suffix, default_format); - - let all_supported: &[&str] = &[ - "png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", "avif", "ico", "pnm", "qoi", - "hdr", "dds", "tga", "exr", "psd", "kra", "hcie", - ]; - let filters: Vec<(&str, &[&str])> = vec![ - ("HCIE Native", &["hcie"]), - ("KRA", &["kra"]), - ("PSD", &["psd"]), - ("PNG", &["png"]), - ("JPEG", &["jpg", "jpeg"]), - ("WebP", &["webp"]), - ("AVIF", &["avif"]), - ("BMP", &["bmp"]), - ("GIF", &["gif"]), - ("TIFF", &["tiff", "tif"]), - ("ICO", &["ico"]), - ("PNM", &["pnm"]), - ("QOI", &["qoi"]), - ("HDR", &["hdr"]), - ("DDS", &["dds"]), - ("TGA", &["tga"]), - ("OpenEXR", &["exr"]), - ]; - - // Start with the format matching the document so it is the active filter. - let mut dialog = rfd::FileDialog::new().set_file_name(file_name); - for (name, exts) in &filters { - if exts.contains(&default_format) { - dialog = dialog.add_filter(*name, *exts); - break; - } - } - // Then add the remaining format filters. - for (name, exts) in &filters { - if !exts.contains(&default_format) { - dialog = dialog.add_filter(*name, *exts); - } - } - // Append a catch-all filter last. - dialog.add_filter("All Supported Types", all_supported) -} - -fn suggest_alternative_path(original: &std::path::Path) -> std::path::PathBuf { - let stem = original - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("untitled"); - let ext = original - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("hcie"); - let file_name = format!("{}_copy.{}", stem, ext); - - if let Some(home) = dirs::home_dir() { - let candidate = home.join(&file_name); - if candidate.parent().map(|p| p.exists()).unwrap_or(false) { - return candidate; - } - } - - std::env::temp_dir().join(file_name) -} - -/// Formats a short, actionable error message for the save/export failure dialog. -/// -/// Detects common I/O errors (permission denied, not found, disk full) and adds a localized -/// hint so the user understands why the operation failed and what to try next. -fn format_save_error_hint(error: &str) -> &'static str { - let lower = error.to_lowercase(); - if lower.contains("permission denied") || lower.contains("os error 13") { - "The selected folder is read-only or you don't have permission to write there." - } else if lower.contains("not found") || lower.contains("os error 2") { - "The parent folder no longer exists." - } else if lower.contains("no space") || lower.contains("os error 28") { - "The destination disk is full." - } else { - "Check that the destination folder exists and is writable." - } -} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/settings.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/settings.rs new file mode 100644 index 0000000..18b5bf1 --- /dev/null +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/settings.rs @@ -0,0 +1,50 @@ +//! Persistent application settings and dock profiles. +//! +//! This module keeps user preferences such as theme, recent files, saved dock +//! layouts, and viewer-mode state. It is intentionally separate from runtime +//! tool state so it can be serialized independently. + +use egui_panel_adapter::ThemePreset; + +/// A single entry in the recent-files list. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct RecentFileEntry { + pub path: String, + pub name: String, +} + +/// A saved dock layout including panel widths and pinned tabs. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct DockProfile { + pub name: String, + pub dock_state_json: String, + pub left_col1_w: f32, + pub left_col2_w: f32, + pub right_col1_w: f32, + pub right_col2_w: f32, + pub last_window_width: f32, + pub pinned_panels: Vec, +} + +/// Top-level persistent settings serialized by eframe on exit. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct Settings { + pub theme: ThemePreset, + /// When `true`, the operating system draws its own window decorations. + /// The custom title bar is always rendered regardless of this value, + /// so the out-of-box experience uses the unified custom bar. + #[serde(default = "default_show_native_decorations")] + pub show_native_decorations: bool, + pub debug_mode: bool, + #[serde(default)] + pub recent_files: Vec, + #[serde(default)] + pub dock_profiles: Vec, + /// Last directory used in Viewer mode, for cross-session persistence. + #[serde(default)] + pub viewer_last_dir: Option, +} + +fn default_show_native_decorations() -> bool { + false +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/theme.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/theme.rs new file mode 100644 index 0000000..a23304b --- /dev/null +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/theme.rs @@ -0,0 +1,250 @@ +//! Visual theme and font setup for the egui GUI. +//! +//! This module centralizes: +//! - System font discovery and registration. +//! - Application of the shared `ThemeColors` / `ThemePreset` into egui visuals. +//! - Helper to retrieve raw default font bytes for the engine text renderer. + +use eframe::egui; + +pub use egui_panel_adapter::{ThemeColors, ThemePreset}; + +/// Register discovered system fonts into the egui context. +/// +/// On Linux/macOS/Windows the first available system sans-serif font is loaded +/// and used as the default proportional font family so the UI matches the OS. +pub fn setup_custom_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + + for (name, data) in discover_system_fonts() { + fonts + .font_data + .insert(name.clone(), std::sync::Arc::new(data)); + // Override the default proportional font family so UI uses OS font + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, name); + } + + ctx.set_fonts(fonts); +} + +/// Load the first available system font and return its (file_name, bytes). +/// This is shared between egui font setup and engine text-renderer registration. +#[cfg(target_os = "linux")] +fn discover_system_fonts() -> Vec<(String, egui::FontData)> { + let mut res = Vec::new(); + let paths = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + ]; + for path in &paths { + if let Ok(bytes) = std::fs::read(path) { + let name = path.rsplit('/').next().unwrap_or("system").to_string(); + res.push((name, egui::FontData::from_owned(bytes))); + break; + } + } + res +} + +#[cfg(target_os = "macos")] +fn discover_system_fonts() -> Vec<(String, egui::FontData)> { + let mut res = Vec::new(); + let paths = [ + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/HelveticaNeue.ttc", + "/Library/Fonts/Arial.ttf", + ]; + for path in &paths { + if let Ok(bytes) = std::fs::read(path) { + let name = path.rsplit('/').next().unwrap_or("system").to_string(); + res.push((name, egui::FontData::from_owned(bytes))); + break; + } + } + res +} + +#[cfg(target_os = "windows")] +fn discover_system_fonts() -> Vec<(String, egui::FontData)> { + let mut res = Vec::new(); + let paths = [ + "C:\\Windows\\Fonts\\arial.ttf", + "C:\\Windows\\Fonts\\segoeui.ttf", + "C:\\Windows\\Fonts\\calibri.ttf", + ]; + for path in &paths { + if let Ok(bytes) = std::fs::read(path) { + let name = path.rsplit('\\').next().unwrap_or("system").to_string(); + res.push((name, egui::FontData::from_owned(bytes))); + break; + } + } + res +} + +/// Returns the raw bytes and a normalized name for the first available system font. +/// Used to seed the engine's `TextRenderer` with a default font on startup. +pub fn default_font_bytes() -> Option<(String, Vec)> { + let paths: &[(&str, &str)] = &[ + #[cfg(target_os = "linux")] + ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "DejaVuSans.ttf", + ), + #[cfg(target_os = "linux")] + ( + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "LiberationSans-Regular.ttf", + ), + #[cfg(target_os = "linux")] + ( + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "NotoSans-Regular.ttf", + ), + #[cfg(target_os = "linux")] + ( + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + "FreeSans.ttf", + ), + #[cfg(target_os = "macos")] + ("/System/Library/Fonts/Helvetica.ttc", "Helvetica.ttc"), + #[cfg(target_os = "macos")] + ( + "/System/Library/Fonts/HelveticaNeue.ttc", + "HelveticaNeue.ttc", + ), + #[cfg(target_os = "macos")] + ("/Library/Fonts/Arial.ttf", "Arial.ttf"), + #[cfg(target_os = "windows")] + ("C:\\Windows\\Fonts\\arial.ttf", "arial.ttf"), + #[cfg(target_os = "windows")] + ("C:\\Windows\\Fonts\\segoeui.ttf", "segoeui.ttf"), + #[cfg(target_os = "windows")] + ("C:\\Windows\\Fonts\\calibri.ttf", "calibri.ttf"), + ]; + for (path, name) in paths { + if let Ok(bytes) = std::fs::read(path) { + return Some(((*name).to_string(), bytes)); + } + } + None +} + +/// **Purpose:** Applies the system-wide visual theme configurations to the egui context. +/// +/// **Logic & Workflow:** +/// 1. Fetches the active `ThemeColors` palette based on the chosen preset. +/// 2. Configures default Light/Dark visuals on the egui Context. +/// 3. Propagates all color tokens directly into `style.visuals` structures so that +/// non-interactive panel elements, idle buttons, hover highlights, active states, +/// scrollbars, text selections, and dropdowns inherit the theme. +/// 4. Harmonizes component padding, margins, and roundings for a clean desktop design. +/// +/// **Arguments:** +/// * `ctx`: The reference to the active egui Context. +/// * `theme`: The selected ThemePreset enum. +pub fn apply_theme(ctx: &egui::Context, theme: ThemePreset) { + let colors = ThemeColors::get(theme); + let is_light = colors.is_light; + ctx.set_visuals(if is_light { + egui::Visuals::light() + } else { + egui::Visuals::dark() + }); + ctx.style_mut(|style| { + // ── Rounded corners (consistent 4px everywhere) ── + let r4 = egui::CornerRadius::same(4); + style.visuals.widgets.noninteractive.corner_radius = r4; + style.visuals.widgets.inactive.corner_radius = r4; + style.visuals.widgets.hovered.corner_radius = r4; + style.visuals.widgets.active.corner_radius = r4; + style.visuals.widgets.open.corner_radius = r4; + + // ── Window / popup rounding ── + style.visuals.window_corner_radius = egui::CornerRadius::same(6); + style.visuals.menu_corner_radius = egui::CornerRadius::same(6); + + // ── Core surface fills (3-tier elevation) ── + style.visuals.window_fill = colors.bg_elevated; + style.visuals.panel_fill = colors.bg_panel; + style.visuals.window_stroke = egui::Stroke::new(1.0, colors.border_high); + style.visuals.extreme_bg_color = colors.bg_recessed; + style.visuals.faint_bg_color = colors.bg_hover.gamma_multiply(0.4); + + // ── Noninteractive widgets (labels, static elements) ── + style.visuals.widgets.noninteractive.bg_fill = colors.bg_panel; + style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, colors.border_low); + style.visuals.widgets.noninteractive.fg_stroke = + egui::Stroke::new(1.0, colors.text_secondary); + + // ── Inactive widgets (idle buttons, slider tracks, checkboxes) ── + style.visuals.widgets.inactive.bg_fill = colors.bg_dark_depth; + style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, colors.border_high); + style.visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, colors.text_primary); + + // ── Hover states ── + style.visuals.widgets.hovered.bg_fill = colors.bg_hover; + style.visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, colors.accent); + style.visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.2, colors.text_primary); + + // ── Active highlights (clicked buttons, active controls) ── + style.visuals.widgets.active.bg_fill = colors.accent; + style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, colors.accent); + style.visuals.widgets.active.fg_stroke = egui::Stroke::new(1.2, colors.text_primary); + + // ── Open menus and popups ── + style.visuals.widgets.open.bg_fill = colors.bg_elevated; + style.visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, colors.border_high); + + // ── Text selection & highlights ── + style.visuals.selection.bg_fill = colors.accent.gamma_multiply(0.3); + style.visuals.selection.stroke = egui::Stroke::new(1.0, colors.accent); + + // ── Slider trailing fill (shows value range) ── + style.visuals.slider_trailing_fill = true; + + // ── Popup shadow for depth ── + style.visuals.popup_shadow = egui::Shadow { + offset: [0, 4], + blur: 16, + spread: 0, + color: egui::Color32::from_black_alpha(if is_light { 18 } else { 60 }), + }; + + // ── Font sizes matching Qt 12px default ── + style.text_styles.insert( + egui::TextStyle::Body, + egui::FontId::proportional(12.0), + ); + style.text_styles.insert( + egui::TextStyle::Button, + egui::FontId::proportional(12.0), + ); + style.text_styles.insert( + egui::TextStyle::Small, + egui::FontId::proportional(11.0), + ); + style.text_styles.insert( + egui::TextStyle::Heading, + egui::FontId::proportional(13.5), + ); + style.text_styles.insert( + egui::TextStyle::Monospace, + egui::FontId::proportional(11.5), + ); + + // ── Harmonized layout parameters (8px grid) ── + style.spacing.item_spacing = egui::vec2(4.0, 4.0); + style.spacing.window_margin = egui::Margin::same(6); + style.spacing.button_padding = egui::vec2(6.0, 3.0); + style.spacing.indent = 12.0; + style.spacing.slider_width = 140.0; + style.spacing.interact_size = egui::vec2(28.0, 22.0); + }); +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/tool_state.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/tool_state.rs new file mode 100644 index 0000000..90a9148 --- /dev/null +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/tool_state.rs @@ -0,0 +1,459 @@ +//! Runtime tool, selection, and transform state. +//! +//! This module contains the interactive state that changes every frame: the +//! active tool, color, brush settings, selection operation, text edit, vector +//! transform, tablet input, and AI/ComfyUI panel state. It is deliberately +//! separate from persistent `Settings`. + +use crate::app::ai_config::{ + AiChatConfig, AiChatMessage, ComfyConfig, ComfyMode, ComfyStatus, VisionBackend, VisionTask, +}; +use crate::app::settings::Settings; +use crate::app::shell::tablet_input::TabletState; +use crate::app::TOOL_SLOTS; +use eframe::egui; +use hcie_engine_api::{Tool, ToolConfigs, VectorEditHandle, VectorShape}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct ToolState { + pub active_tool: Tool, + pub primary_color: [u8; 4], + pub secondary_color: [u8; 4], + pub recent_colors: Vec<[u8; 4]>, + pub tool_configs: ToolConfigs, + pub current_pressure: f32, + pub pan_offset: egui::Vec2, + pub cursor_canvas_pos: Option<(u32, u32)>, + pub selection_rect: Option<[u32; 4]>, + /// VisionSelect drag rectangle in canvas coordinates, used for bbox segmentation. + pub vision_select_rect: Option<[u32; 4]>, + pub is_drawing: bool, + pub drag_start_pos: Option<(u32, u32)>, + pub last_draw_pos: Option<(u32, u32)>, + pub brush_accumulated_dist: f32, + pub lasso_points: Vec<(u32, u32)>, + pub smart_patch_points: Vec<(u32, u32)>, + pub text_edit: TextEditState, + pub selected_vector_shape: Option, + pub vector_shapes_snapshot: Option>, + pub vector_is_new_creation: bool, + pub vector_edit_handle: VectorEditHandle, + pub vector_drag_last: Option, + pub settings: Settings, + pub pinned_panels: std::collections::HashSet, + /// Set of utility panels that the user has collapsed with the custom + /// collapse chevron on the active tab. + pub collapsed_panels: std::collections::HashSet, + pub ai_panel_tab: usize, + pub ai_chat_thinking: bool, + pub is_ai_processing: bool, + pub ai_actions_busy: bool, + pub ai_chat_history: Vec, + pub ai_chat_config: AiChatConfig, + pub ai_chat_models: Vec, + pub ai_send_canvas: bool, + pub ai_vision_review: bool, + pub ai_chat_show_reasoning: bool, + pub ai_chat_max_turns: u32, + pub ai_chat_buffer: String, + pub comfy_status: ComfyStatus, + pub comfy_config: ComfyConfig, + pub comfy_mode: ComfyMode, + pub brush_presets: Vec, + pub active_brush_preset_id: String, + pub comfy_models: Vec, + pub selected_model: String, + pub vision_task: VisionTask, + pub vision_sam_model_path: String, + pub vision_birefnet_model_path: String, + pub vision_migan_model_path: String, + pub vision_backend: VisionBackend, + pub script_editor: egui_panel_script::ScriptEditor, + pub ai_script_generator: egui_panel_ai_script::AiScriptGenerator, + pub vector_shapes_expanded: bool, + pub tool_scroll_offset: f32, + pub transform: Option, + pub transform_drag_handle: TransformHandle, + /// Shared tablet/stylus state updated by winit/evdev and read each frame. + pub tablet_state: Arc>, + pub active_slot: usize, + pub slot_last_used: Vec, + pub active_popup_slot: Option, + pub popup_anchor_pos: Option, + pub popup_close_request: bool, + pub slot_press_start: Option<(usize, std::time::Instant)>, + pub popup_opened_via_drag: bool, + /// If true, the left toolbox is rendered as a two-column wide strip. + pub toolbox_two_column: bool, + /// When set, the active layer is locked to this id while a destructive + /// preview dialog (e.g. Adjustments) is open. The Layers panel refuses to + /// switch the active layer while this is set, and the app re-asserts the + /// locked layer every frame so programmatic layer changes (shortcuts, + /// canvas auto-create, etc.) cannot move the filter target to another + /// layer. Cleared when the owning dialog closes. + pub locked_active_layer: Option, + /// Whether the recent colors popup/expand section is shown. + pub show_recent_popup: bool, + /// Pending name input used by the brush editor "New Preset" action. + pub pending_brush_preset_name: Option, + /// Whether the brush editor updates the canvas brush preview live. + pub brush_editor_instant_preview: bool, + pub is_resizing_brush: bool, + pub brush_resize_start_size: f32, + pub brush_resize_start_pos: Option, + /// Cached brush preset thumbnail textures keyed by preset ID. + /// Generated once when ABR/KPP files are loaded or on explicit refresh. + pub brush_thumbnail_cache: std::collections::HashMap, + /// When true, all cached thumbnails are regenerated on the next frame. + pub brush_thumbnail_dirty: bool, +} + +impl Default for ToolState { + fn default() -> Self { + let mut pinned = std::collections::HashSet::new(); + pinned.insert("Tools".to_string()); + pinned.insert("Brushes & Tips".to_string()); + pinned.insert("Color Palette".to_string()); + pinned.insert("Layers".to_string()); + Self { + active_tool: Tool::Brush, + primary_color: [0, 0, 0, 255], + secondary_color: [255, 255, 255, 255], + recent_colors: vec![], + tool_configs: ToolConfigs::default(), + current_pressure: 1.0, + pan_offset: egui::Vec2::ZERO, + cursor_canvas_pos: None, + selection_rect: None, + vision_select_rect: None, + is_drawing: false, + drag_start_pos: None, + last_draw_pos: None, + brush_accumulated_dist: 0.0, + lasso_points: vec![], + smart_patch_points: vec![], + text_edit: TextEditState::default(), + selected_vector_shape: None, + vector_shapes_snapshot: None, + vector_is_new_creation: false, + vector_edit_handle: VectorEditHandle::None, + vector_drag_last: None, + settings: Settings::default(), + pinned_panels: pinned, + collapsed_panels: std::collections::HashSet::new(), + ai_panel_tab: 0, + ai_chat_thinking: false, + is_ai_processing: false, + ai_actions_busy: false, + ai_chat_history: vec![], + ai_chat_config: AiChatConfig { + url: "http://127.0.0.1:8188".into(), + model: "flux".into(), + }, + ai_chat_models: vec![], + ai_send_canvas: false, + ai_vision_review: false, + ai_chat_show_reasoning: false, + ai_chat_max_turns: 10, + ai_chat_buffer: String::new(), + comfy_status: ComfyStatus::Unknown, + comfy_config: ComfyConfig { + url: "http://127.0.0.1:8188".into(), + }, + comfy_mode: ComfyMode::Normal, + brush_presets: hcie_engine_api::BrushPreset::all_defaults(), + active_brush_preset_id: "basic_round".to_string(), + comfy_models: vec![], + selected_model: String::new(), + vision_task: VisionTask::default(), + vision_sam_model_path: "models/sam/MobileSAM-F16.gguf".to_string(), + vision_birefnet_model_path: "models/birefnet/BiRefNet-lite-F16.gguf".to_string(), + vision_migan_model_path: "models/migan/MIGAN-512-places2-F16.gguf".to_string(), + vision_backend: VisionBackend::default(), + script_editor: egui_panel_script::ScriptEditor::new(), + ai_script_generator: egui_panel_ai_script::AiScriptGenerator::new(), + vector_shapes_expanded: false, + tool_scroll_offset: 0.0, + transform: None, + transform_drag_handle: TransformHandle::None, + tablet_state: Arc::new(std::sync::Mutex::new(TabletState::new())), + active_slot: TOOL_SLOTS + .iter() + .position(|s| s.contains(&Tool::Brush)) + .unwrap_or(0), + slot_last_used: { + let mut v = vec![0usize; TOOL_SLOTS.len()]; + if let Some(si) = TOOL_SLOTS.iter().position(|s| s.contains(&Tool::Brush)) { + if let Some(li) = TOOL_SLOTS[si].iter().position(|&t| t == Tool::Brush) { + v[si] = li; + } + } + v + }, + active_popup_slot: None, + popup_anchor_pos: None, + popup_close_request: false, + slot_press_start: None, + popup_opened_via_drag: false, + toolbox_two_column: false, + locked_active_layer: None, + show_recent_popup: false, + pending_brush_preset_name: None, + brush_editor_instant_preview: true, + is_resizing_brush: false, + brush_resize_start_size: 10.0, + brush_resize_start_pos: None, + brush_thumbnail_cache: std::collections::HashMap::new(), + brush_thumbnail_dirty: true, + } + } +} + +impl ToolState { + pub fn active_size(&self) -> f32 { + match self.active_tool { + Tool::Pen => self.tool_configs.pen.size, + Tool::Brush => self.tool_configs.brush.size, + Tool::Eraser => self.tool_configs.eraser.size, + Tool::Spray => self.tool_configs.spray.radius, + _ => 10.0, + } + } + + pub fn set_active_size(&mut self, size: f32) { + match self.active_tool { + Tool::Pen => self.tool_configs.pen.size = size, + Tool::Brush => self.tool_configs.brush.size = size, + Tool::Eraser => self.tool_configs.eraser.size = size, + Tool::Spray => self.tool_configs.spray.radius = size, + _ => {} + } + } + + pub fn active_size_mut(&mut self) -> &mut f32 { + match self.active_tool { + Tool::Pen => &mut self.tool_configs.pen.size, + Tool::Brush => &mut self.tool_configs.brush.size, + Tool::Eraser => &mut self.tool_configs.eraser.size, + Tool::Spray => &mut self.tool_configs.spray.radius, + _ => &mut self.tool_configs.pen.size, + } + } + + pub fn active_opacity(&self) -> f32 { + match self.active_tool { + Tool::Pen => self.tool_configs.pen.opacity, + Tool::Brush => self.tool_configs.brush.opacity, + Tool::Eraser => self.tool_configs.eraser.opacity, + Tool::Spray => self.tool_configs.spray.opacity, + _ => 1.0, + } + } + + pub fn set_active_opacity(&mut self, opacity: f32) { + match self.active_tool { + Tool::Pen => self.tool_configs.pen.opacity = opacity, + Tool::Brush => self.tool_configs.brush.opacity = opacity, + Tool::Eraser => self.tool_configs.eraser.opacity = opacity, + Tool::Spray => self.tool_configs.spray.opacity = opacity, + _ => {} + } + } + + pub fn brush_dirty_radius(&self) -> f32 { + self.active_size() * 0.5 + 2.0 + } + + pub fn slot_for_tool(tool: Tool) -> Option { + TOOL_SLOTS.iter().position(|slot| slot.contains(&tool)) + } + + pub fn set_active_tool_from_slot(&mut self, tool: Tool) { + if let Some(slot_idx) = Self::slot_for_tool(tool) { + self.active_slot = slot_idx; + if let Some(local_idx) = TOOL_SLOTS[slot_idx].iter().position(|&t| t == tool) { + self.slot_last_used[slot_idx] = local_idx; + } + } + self.active_tool = tool; + } +} + +/// Selection morphological operations shown in the context menu / dialog. +pub enum SelectionOp { + Grow(u32), + Shrink(u32), + Feather(u32), + Erode(u32), + Fade(u32), +} + +/// Live text editing state used by the inline canvas text tool. +/// +/// When the user clicks on the canvas with the Text tool, a draft text +/// layer is created and its properties are kept here. All changes in the +/// Properties panel or in the on-canvas text edit are applied to the draft +/// layer immediately so the user sees a live preview. The edits are only +/// committed when Accept is pressed; Cancel discards a newly created draft +/// layer, or reverts an existing text layer to the snapshot stored in +/// `original_text` and its companion `original_*` fields. +#[derive(Clone)] +pub struct TextEditState { + pub active: bool, + pub layer_id: u64, + pub draft_text: String, + pub draft_font: String, + pub draft_size: f32, + pub draft_color: [u8; 4], + pub draft_angle: f32, + pub draft_alignment: hcie_engine_api::TextAlignment, + pub draft_orientation: hcie_engine_api::TextOrientation, + pub canvas_x: u32, + pub canvas_y: u32, + pub accepted: bool, + pub cancelled: bool, + /// If true, the layer was created by the current inline edit session and + /// should be deleted on Cancel. If false, Cancel reverts to `original_*`. + pub is_new: bool, + /// Snapshot of the layer's text and properties taken when an existing text + /// layer enters edit mode. Used to restore the layer on Cancel. + pub original_text: String, + pub original_font: String, + pub original_size: f32, + pub original_color: [u8; 4], + pub original_angle: f32, + pub original_alignment: hcie_engine_api::TextAlignment, + pub original_orientation: hcie_engine_api::TextOrientation, +} + +impl Default for TextEditState { + fn default() -> Self { + Self { + active: false, + layer_id: 0, + draft_text: String::new(), + draft_font: "default".to_string(), + draft_size: 24.0, + draft_color: [0, 0, 0, 255], + draft_angle: 0.0, + draft_alignment: hcie_engine_api::TextAlignment::Left, + draft_orientation: hcie_engine_api::TextOrientation::Horizontal, + canvas_x: 0, + canvas_y: 0, + accepted: false, + cancelled: false, + is_new: true, + original_text: String::new(), + original_font: "default".to_string(), + original_size: 24.0, + original_color: [0, 0, 0, 255], + original_angle: 0.0, + original_alignment: hcie_engine_api::TextAlignment::Left, + original_orientation: hcie_engine_api::TextOrientation::Horizontal, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TransformHandle { + #[default] + None, + Move, + TopLeft, + TopRight, + BottomRight, + BottomLeft, + Top, + Bottom, + Left, + Right, +} + +#[derive(Clone)] +pub struct SelectionTransform { + pub pixels: Vec, + pub width: u32, + pub height: u32, + pub pos: egui::Vec2, + pub size: egui::Vec2, +} + +impl SelectionTransform { + pub fn from_engine(engine: &hcie_engine_api::Engine, mask: &[u8], w: u32, h: u32) -> Self { + if mask.is_empty() || w == 0 || h == 0 { + return Self { + pixels: vec![], + width: 0, + height: 0, + pos: egui::Vec2::ZERO, + size: egui::Vec2::ZERO, + }; + } + let mut min_x = w; + let mut min_y = h; + let mut max_x = 0u32; + let mut max_y = 0u32; + let mut has_selection = false; + + for y in 0..h { + for x in 0..w { + if mask[(y * w + x) as usize] > 0 { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + has_selection = true; + } + } + } + + if !has_selection { + return Self { + pixels: vec![], + width: 0, + height: 0, + pos: egui::Vec2::ZERO, + size: egui::Vec2::ZERO, + }; + } + + let bw = max_x - min_x + 1; + let bh = max_y - min_y + 1; + let mut pixels = vec![0u8; (bw * bh * 4) as usize]; + + let layer_pixels = engine.get_active_layer_pixels().unwrap_or_default(); + let layer_w = engine.canvas_width() as usize; + let w_usize = w as usize; + let bw_usize = bw as usize; + let bh_usize = bh as usize; + let min_x_usize = min_x as usize; + let min_y_usize = min_y as usize; + + for y in 0..bh_usize { + for x in 0..bw_usize { + let src_x = min_x_usize + x; + let src_y = min_y_usize + y; + let mask_idx = src_y * w_usize + src_x; + if mask_idx < mask.len() && mask[mask_idx] > 0 { + let px_idx = (src_y * layer_w + src_x) * 4; + if px_idx + 3 < layer_pixels.len() { + let dst_idx = (y * bw_usize + x) * 4; + pixels[dst_idx] = layer_pixels[px_idx]; + pixels[dst_idx + 1] = layer_pixels[px_idx + 1]; + pixels[dst_idx + 2] = layer_pixels[px_idx + 2]; + pixels[dst_idx + 3] = layer_pixels[px_idx + 3]; + } + } + } + } + + Self { + pixels, + width: bw, + height: bh, + pos: egui::Vec2::new(min_x as f32, min_y as f32), + size: egui::Vec2::new(bw as f32, bh as f32), + } + } +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/brushes_panel.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/brushes_panel.rs index ef39f68..103cdcd 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/brushes_panel.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/brushes_panel.rs @@ -4,6 +4,11 @@ use eframe::egui; use hcie_engine_api::brush::{BrushStyle, BrushTip}; use hcie_engine_api::BrushPreset; use hcie_engine_api::Tool; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +const THUMB_W: u32 = 140; +const THUMB_H: u32 = 64; fn matches_category(active_cat: &str, preset_cat: &str) -> bool { if active_cat == "All" { @@ -12,6 +17,9 @@ fn matches_category(active_cat: &str, preset_cat: &str) -> bool { if active_cat == "Custom" { return preset_cat.eq_ignore_ascii_case("Custom"); } + if active_cat == "Imported" { + return preset_cat.eq_ignore_ascii_case("Imported") || preset_cat.eq_ignore_ascii_case("Imported (ABR)"); + } let mapped: &[&str] = match active_cat { "Drawing" => &["Basic", "Sketch"], "Painting" => &["Paint", "Ink"], @@ -21,12 +29,158 @@ fn matches_category(active_cat: &str, preset_cat: &str) -> bool { mapped.iter().any(|c| c.eq_ignore_ascii_case(preset_cat)) } -/// Draw a gorgeous custom procedural preview of a brush style inside a rect. +fn preset_thumb_hash(preset: &BrushPreset) -> u64 { + let mut hasher = DefaultHasher::new(); + preset.style.hash(&mut hasher); + preset.tip.size.to_bits().hash(&mut hasher); + preset.tip.hardness.to_bits().hash(&mut hasher); + preset.tip.opacity.to_bits().hash(&mut hasher); + preset.tip.roundness.to_bits().hash(&mut hasher); + preset.tip.spacing.to_bits().hash(&mut hasher); + preset.tip.bitmap_w.hash(&mut hasher); + preset.tip.bitmap_h.hash(&mut hasher); + if !preset.tip.bitmap_pixels.is_empty() { + let step = (preset.tip.bitmap_pixels.len() / 64).max(1); + for (_i, &v) in preset.tip.bitmap_pixels.iter().enumerate().step_by(step) { + v.hash(&mut hasher); + } + } + hasher.finish() +} + +fn render_thumb_to_texture( + ctx: &egui::Context, + preset: &BrushPreset, +) -> egui::TextureHandle { + let w = THUMB_W as usize; + let h = THUMB_H as usize; + let mut pixels = vec![0u8; w * h * 4]; + render_thumb_into_rgba(preset, &mut pixels, w, h); + let image = egui::ColorImage::from_rgba_unmultiplied([w, h], &pixels); + ctx.load_texture(format!("thumb_{}", preset.id), image, egui::TextureOptions::LINEAR) +} + +fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usize) { + let style = preset.style; + let gray: [u8; 4] = [200, 200, 200, 255]; + let x0f = 16.0_f32; + let x1f = (w as f32) - 16.0; + let y_mid = (h as f32) * 0.5; + let n = 45; + let mut pts: Vec<(f32, f32, f32)> = Vec::with_capacity(n + 1); + for i in 0..=n { + let t = i as f32 / n as f32; + let x = x0f + t * (x1f - x0f); + let angle = t * std::f32::consts::TAU * 1.25; + let taper = (t * (1.0 - t) * 4.0).powf(1.1); + let y = y_mid + angle.sin() * (h as f32 * 0.22) * taper; + pts.push((x, y, t)); + } + match style { + BrushStyle::Bitmap => { + if preset.tip.bitmap_pixels.is_empty() || preset.tip.bitmap_w == 0 || preset.tip.bitmap_h == 0 { + return; + } + let bw = preset.tip.bitmap_w as f32; + let bh = preset.tip.bitmap_h as f32; + let scale = (w as f32 / bw).min(h as f32 / bh); + let draw_w = bw * scale; + let draw_h = bh * scale; + let ox = ((w as f32) - draw_w) * 0.5; + let oy = ((h as f32) - draw_h) * 0.5; + let px = scale.max(1.0); + let mut sy = 0u32; + let mut py = 0.0; + while sy < preset.tip.bitmap_h { + let mut sx = 0u32; + let mut ppx = 0.0; + while sx < preset.tip.bitmap_w { + let mask = preset.tip.bitmap_pixels[(sy * preset.tip.bitmap_w + sx) as usize] as f32 / 255.0; + if mask > 0.01 { + let a = (mask * 255.0).round() as u8; + let rx = (ox + ppx).round() as i32; + let ry = (oy + py).round() as i32; + let rw = px.ceil() as i32; + let rh = px.ceil() as i32; + for dy in 0..rh { + for dx in 0..rw { + let px_i = rx + dx; + let py_i = ry + dy; + if px_i >= 0 && py_i >= 0 && (px_i as usize) < w && (py_i as usize) < h { + let idx = ((py_i as usize) * w + (px_i as usize)) * 4; + buf[idx] = gray[0]; + buf[idx + 1] = gray[1]; + buf[idx + 2] = gray[2]; + buf[idx + 3] = a; + } + } + } + } + ppx += px; + sx += 1; + } + py += px; + sy += 1; + } + return; + } + _ => {} + } + let thickness_base = match style { + BrushStyle::Square => 2.0, + BrushStyle::Pen => 1.0, + BrushStyle::Pencil => 1.0, + BrushStyle::Airbrush => 6.0, + BrushStyle::Oil => 6.5, + BrushStyle::Glow => 2.5, + BrushStyle::InkPen => 0.9, + BrushStyle::Wood => 3.8, + _ => 2.0, + }; + for i in 0..n { + let (x1, y1, t1) = pts[i]; + let (x2, y2, _) = pts[i + 1]; + let p = (t1 * std::f32::consts::PI).sin(); + let thick = thickness_base * p; + let steps_i = (thick * 2.0).ceil() as i32; + for step in 0..steps_i.max(1) { + let frac = step as f32 / (steps_i.max(1) as f32) - 0.5; + let nx = -(y2 - y1); + let ny = x2 - x1; + let len = (nx * nx + ny * ny).sqrt().max(0.001); + let off_x = nx / len * frac * thick; + let off_y = ny / len * frac * thick; + let sx_f = x1 + off_x; + let sy_f = y1 + off_y; + let ex_f = x2 + off_x; + let ey_f = y2 + off_y; + let dist_x = (ex_f - sx_f).abs(); + let dist_y = (ey_f - sy_f).abs(); + let steps = (dist_x.max(dist_y).ceil() as i32).max(1); + for s in 0..=steps { + let t = s as f32 / steps as f32; + let px_f = sx_f + (ex_f - sx_f) * t; + let py_f = sy_f + (ey_f - sy_f) * t; + let r = px_f.round() as i32; + let c = py_f.round() as i32; + if r >= 0 && c >= 0 && (r as usize) < w && (c as usize) < h { + let idx = ((c as usize) * w + (r as usize)) * 4; + buf[idx] = gray[0]; + buf[idx + 1] = gray[1]; + buf[idx + 2] = gray[2]; + buf[idx + 3] = gray[3]; + } + } + } + } +} + pub fn draw_brush_preview( painter: &egui::Painter, rect: egui::Rect, style: hcie_engine_api::tools::BrushStyle, - color: egui::Color32, + _color: egui::Color32, + tip: Option<&BrushTip>, ) { let _width = rect.width(); let height = rect.height(); @@ -39,7 +193,6 @@ pub fn draw_brush_preview( for i in 0..=n_points { let t = i as f32 / n_points as f32; let x = x0 + t * (x1 - x0); - // Tapered wave formula for S-curve let angle = t * std::f32::consts::TAU * 1.25; let sine = angle.sin(); let taper = (t * (1.0 - t) * 4.0).powf(1.1); @@ -47,12 +200,12 @@ pub fn draw_brush_preview( points.push((x, y, t)); } + let color = egui::Color32::from_gray(200); match style { hcie_engine_api::tools::BrushStyle::Default | hcie_engine_api::tools::BrushStyle::Round | hcie_engine_api::tools::BrushStyle::HardRound | hcie_engine_api::tools::BrushStyle::SoftRound => { - // Smooth tapered round path for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -65,7 +218,6 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Square => { - // Squared-off strokes with flat ends for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -78,7 +230,6 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Noise => { - // Grainy jittery stroke for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -92,27 +243,17 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Texture => { - // Rough dotted texture stroke for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); - if p < 0.15 { - continue; - } + if p < 0.15 { continue; } let alpha = 0.3 + 0.5 * (x * 23.0).sin().abs(); - painter.circle_filled( - egui::pos2(x, y), - 1.2 + 2.0 * p, - color.linear_multiply(alpha), - ); + painter.circle_filled(egui::pos2(x, y), 1.2 + 2.0 * p, color.linear_multiply(alpha)); } } hcie_engine_api::tools::BrushStyle::Spray => { - // Scattered spray particles for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); - if p < 0.05 { - continue; - } + if p < 0.05 { continue; } for s in 0..6 { let angle = (x * 9.0 + s as f32 * 1.1).sin() * std::f32::consts::TAU; let dist = (y * 11.0 + s as f32 * 1.7).cos().abs() * 8.0 * p; @@ -123,28 +264,24 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Pen => { - // Crisp thin pen line for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; let p = (t1 * std::f32::consts::PI).sin(); - let thickness = 1.0 + 1.2 * p; painter.line_segment( [egui::pos2(x1, y1), egui::pos2(x2, y2)], - egui::Stroke::new(thickness, color), + egui::Stroke::new(1.0 + 1.2 * p, color), ); } } hcie_engine_api::tools::BrushStyle::Airbrush => { - // Soft transparent wide overlay for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; let p = (t1 * std::f32::consts::PI).sin(); - let base_color = color.linear_multiply(0.08); painter.line_segment( [egui::pos2(x1, y1), egui::pos2(x2, y2)], - egui::Stroke::new(14.0 * p, base_color), + egui::Stroke::new(14.0 * p, color.linear_multiply(0.08)), ); painter.line_segment( [egui::pos2(x1, y1), egui::pos2(x2, y2)], @@ -153,43 +290,33 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Pencil => { - // Thin scratchy slightly noisy line for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; let p = (t1 * std::f32::consts::PI).sin(); - let noise1 = (x1 * 13.0).sin() * 0.35; - let noise2 = (x2 * 13.0).sin() * 0.35; + let n1 = (x1 * 13.0).sin() * 0.35; + let n2 = (x2 * 13.0).sin() * 0.35; painter.line_segment( - [egui::pos2(x1, y1 + noise1), egui::pos2(x2, y2 + noise2)], + [egui::pos2(x1, y1 + n1), egui::pos2(x2, y2 + n2)], egui::Stroke::new(1.0 + 0.8 * p, color.linear_multiply(0.9)), ); } } hcie_engine_api::tools::BrushStyle::Charcoal => { - // Rough scratchy textured points for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); - if p < 0.1 { - continue; - } - let steps = 4; - for s in 0..steps { + if p < 0.1 { continue; } + for s in 0..4 { let angle = (x * 7.3 + s as f32).sin() * std::f32::consts::TAU; let dist = (x * 11.2 + s as f32).cos().abs() * 3.5 * p; let px = x + angle.cos() * dist; let py = y + angle.sin() * dist; let alpha = 0.25 + 0.55 * (x * 19.3).sin().abs(); - painter.circle_filled( - egui::pos2(px, py), - 1.0 + 1.2 * p, - color.linear_multiply(alpha), - ); + painter.circle_filled(egui::pos2(px, py), 1.0 + 1.2 * p, color.linear_multiply(alpha)); } } } hcie_engine_api::tools::BrushStyle::Watercolor => { - // Bleeding watercolor blobs with wet edges for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); let r = 8.0 * p + 1.5; @@ -198,12 +325,10 @@ pub fn draw_brush_preview( for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); let r = 8.0 * p + 1.5; - let edge_color = color.linear_multiply(0.12); - painter.circle_stroke(egui::pos2(x, y), r, egui::Stroke::new(0.8, edge_color)); + painter.circle_stroke(egui::pos2(x, y), r, egui::Stroke::new(0.8, color.linear_multiply(0.12))); } } hcie_engine_api::tools::BrushStyle::Calligraphy => { - // Slanted ribbon lines for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); let w = 4.2 * p; @@ -214,165 +339,61 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Leaf => { - // Scattered oval leaf shapes with color variation, shadow, and stem for (i, &(x, y, t)) in points.iter().enumerate() { - if i % 4 != 0 { - continue; - } + if i % 4 != 0 { continue; } let p = (t * std::f32::consts::PI).sin(); - if p < 0.15 { - continue; - } - // Vary color per leaf (darker/lighter) + if p < 0.15 { continue; } let shade = 0.35 + 0.55 * (i as f32 * 0.7).sin().abs(); - let leaf_color = color.linear_multiply(shade); - let leaf_dark = color.linear_multiply(shade * 0.55); - // Leaf size with slight variation let size = 1.8 + 2.0 * p; - // Slight horizontal offset for natural scatter let lx = x + (i as f32 * 1.3).sin() * 1.5; - // Draw leaf as small vertical oval (pill shape) let leaf_rect = egui::Rect::from_center_size( - egui::pos2(lx, y), - egui::vec2(size * 0.45, size * 1.1), + egui::pos2(lx, y), egui::vec2(size * 0.45, size * 1.1), ); - // Shadow below and slightly to the right - let shadow_rect = leaf_rect.translate(egui::vec2(0.4, 0.6)); painter.rect_filled( - shadow_rect, + leaf_rect.translate(egui::vec2(0.4, 0.6)), egui::CornerRadius::same((size * 0.22).round() as u8), - leaf_dark.linear_multiply(0.35), + color.linear_multiply(shade * 0.55 * 0.35), ); - // Leaf body painter.rect_filled( leaf_rect, egui::CornerRadius::same((size * 0.22).round() as u8), - leaf_color, - ); - // Top-half highlight - let hl_rect = egui::Rect::from_min_max( - leaf_rect.left_top(), - egui::pos2(leaf_rect.right(), leaf_rect.center().y), - ); - painter.rect_filled( - hl_rect, - egui::CornerRadius::same(1), - leaf_color.linear_multiply(1.25), - ); - // Thin outline - painter.rect_stroke( - leaf_rect, - egui::CornerRadius::same((size * 0.22).round() as u8), - egui::Stroke::new(0.4, leaf_dark), - egui::StrokeKind::Outside, - ); - // Tiny stem at bottom - let stem_top = egui::pos2(lx, leaf_rect.bottom()); - let stem_bot = egui::pos2(lx + 0.5, leaf_rect.bottom() + size * 0.5); - painter.line_segment( - [stem_top, stem_bot], - egui::Stroke::new(0.5, leaf_dark.linear_multiply(0.6)), + color.linear_multiply(shade), ); } } hcie_engine_api::tools::BrushStyle::Rock => { - // Irregular rounded rocks with bottom shadow and top highlight for (i, &(x, y, t)) in points.iter().enumerate() { - if i % 2 != 0 { - continue; - } + if i % 2 != 0 { continue; } let p = (t * std::f32::consts::PI).sin(); - if p < 0.1 { - continue; - } - // Rock dimensions with variation - let rw = 3.0 + 3.5 * p * (0.7 + 0.3 * (i as f32 * 1.3).sin().abs()); - let rh = 2.0 + 2.5 * p * (0.7 + 0.3 * (i as f32 * 0.9).cos().abs()); - let rounding = 1.5 + 2.5 * (i as f32 * 0.5).sin().abs(); + if p < 0.1 { continue; } + let rw = 3.0 + 3.5 * p; + let rh = 2.0 + 2.5 * p; let shade = 0.40 + 0.40 * (i as f32 * 0.7).sin().abs(); - let rock_color = color.linear_multiply(shade); - let rock_dark = color.linear_multiply(shade * 0.55); let rx = x + (i as f32).sin() * 1.5; let ry = y + (i as f32 * 0.7).cos() * 1.0; - let rock_rect = - egui::Rect::from_center_size(egui::pos2(rx, ry), egui::vec2(rw, rh)); - // Bottom shadow (lower half of rock, darker) - let shadow_rect = egui::Rect::from_min_max( - egui::pos2(rock_rect.left(), rock_rect.center().y - rh * 0.1), - rock_rect.right_bottom(), - ); - painter.rect_filled(shadow_rect, egui::CornerRadius::same(1), rock_dark); - // Rock body + let rock_rect = egui::Rect::from_center_size(egui::pos2(rx, ry), egui::vec2(rw, rh)); painter.rect_filled( - rock_rect, - egui::CornerRadius::same(rounding.round() as u8), - rock_color, - ); - // Dark outline - painter.rect_stroke( - rock_rect, - egui::CornerRadius::same(rounding.round() as u8), - egui::Stroke::new(0.6, rock_dark.linear_multiply(0.8)), - egui::StrokeKind::Outside, - ); - // Top-left highlight - let hl_rect = egui::Rect::from_min_max( - rock_rect.left_top(), - egui::pos2(rock_rect.left() + rw * 0.4, rock_rect.top() + rh * 0.35), - ); - painter.rect_filled( - hl_rect, - egui::CornerRadius::same(1), - rock_color.linear_multiply(1.3), + rock_rect, egui::CornerRadius::same(2), color.linear_multiply(shade), ); } } hcie_engine_api::tools::BrushStyle::Meadow => { - // Curved grass blades drawn as thin line segments, with color variation and shadow for (i, &(x, y, t)) in points.iter().enumerate() { - if i % 2 != 0 { - continue; - } + if i % 2 != 0 { continue; } let p = (t * std::f32::consts::PI).sin(); - if p < 0.1 { - continue; - } - // 2-3 blades per point for density - let blade_count = 2 + (i % 2); - for b in 0..blade_count { - let offset_x = (i as f32 * 3.7 + b as f32 * 5.1).sin() * 2.5; - let bx = x + offset_x; - let blade_h = - (4.0 + 5.0 * p) * (0.7 + 0.3 * (i as f32 * 1.1 + b as f32).sin().abs()); - let wind = (bx * 5.0 + i as f32 * 0.5).sin() * 2.0; - let shade = 0.35 + 0.45 * (b as f32 * 0.7 + p).sin().abs(); - let blade_color = color.linear_multiply(shade); - let blade_dark = color.linear_multiply(shade * 0.5); - // Curved blade as 2 line segments (base→mid→tip) - let base = egui::pos2(bx, y); - let mid = egui::pos2(bx + wind * 0.4, y - blade_h * 0.5); - let tip = egui::pos2(bx + wind, y - blade_h); - // Shadow at base - painter.circle_filled(base, 0.8, blade_dark.linear_multiply(0.5)); - // Lower half (thicker stem) - painter.line_segment([base, mid], egui::Stroke::new(1.0, blade_color)); - // Upper half (thin tip) - painter.line_segment( - [mid, tip], - egui::Stroke::new(0.5, blade_color.linear_multiply(0.9)), - ); - // Thin highlight on left side - let hl_start = egui::pos2(base.x - 0.3, base.y - 0.3); - let hl_mid = egui::pos2(mid.x - 0.3, mid.y - 0.3); - painter.line_segment( - [hl_start, hl_mid], - egui::Stroke::new(0.2, blade_color.linear_multiply(1.3)), - ); - } + if p < 0.1 { continue; } + let blade_h = 4.0 + 5.0 * p; + let wind = (x * 5.0 + i as f32 * 0.5).sin() * 2.0; + let shade = 0.35 + 0.45 * (i as f32 * 0.7 + p).sin().abs(); + let blade_color = color.linear_multiply(shade); + let base = egui::pos2(x, y); + let mid = egui::pos2(x + wind * 0.4, y - blade_h * 0.5); + let tip = egui::pos2(x + wind, y - blade_h); + painter.line_segment([base, mid], egui::Stroke::new(1.0, blade_color)); + painter.line_segment([mid, tip], egui::Stroke::new(0.5, blade_color.linear_multiply(0.9))); } } hcie_engine_api::tools::BrushStyle::Wood => { - // Wood grain overlay for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -381,15 +402,9 @@ pub fn draw_brush_preview( [egui::pos2(x1, y1), egui::pos2(x2, y2)], egui::Stroke::new(3.8 * p, color.linear_multiply(0.75)), ); - let offset = 4.2 * p; - painter.line_segment( - [egui::pos2(x1, y1 + offset), egui::pos2(x2, y2 + offset)], - egui::Stroke::new(1.0 * p, color.linear_multiply(0.45)), - ); } } hcie_engine_api::tools::BrushStyle::Oil => { - // Thick oily strokes with sheen highlights for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -405,7 +420,6 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Glow => { - // Glowing neon core for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -425,24 +439,14 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Clouds => { - // Dreamy circular puffy blobs for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); let r = 8.5 * p; - painter.circle_filled( - egui::pos2(x, y), - r, - egui::Color32::WHITE.linear_multiply(0.16), - ); - painter.circle_filled( - egui::pos2(x, y - 2.0), - r * 0.8, - egui::Color32::WHITE.linear_multiply(0.24), - ); + painter.circle_filled(egui::pos2(x, y), r, egui::Color32::WHITE.linear_multiply(0.16)); + painter.circle_filled(egui::pos2(x, y - 2.0), r * 0.8, egui::Color32::WHITE.linear_multiply(0.24)); } } hcie_engine_api::tools::BrushStyle::Tree => { - // Branch structures for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -452,19 +456,15 @@ pub fn draw_brush_preview( egui::Stroke::new(3.0 * p + 1.0, color), ); if i == n_points / 3 || i == 2 * n_points / 3 { - let branch_dir = if i == n_points / 3 { -1.0 } else { 1.0 }; + let d = if i == n_points / 3 { -1.0 } else { 1.0 }; painter.line_segment( - [ - egui::pos2(x1, y1), - egui::pos2(x1 + 6.0, y1 + branch_dir * 8.0), - ], + [egui::pos2(x1, y1), egui::pos2(x1 + 6.0, y1 + d * 8.0)], egui::Stroke::new(1.5 * p, color), ); } } } hcie_engine_api::tools::BrushStyle::Bristle => { - // Separated bristle hair strands for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -479,42 +479,28 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Crayon => { - // Textured crayon look for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); - if p < 0.1 { - continue; - } + if p < 0.1 { continue; } for s in 0..3 { let jx = (x * 17.5 + s as f32 * 5.0).sin() * 2.0 * p; let jy = (y * 23.4 + s as f32 * 9.0).cos() * 2.0 * p; - painter.circle_filled( - egui::pos2(x + jx, y + jy), - 1.2 * p, - color.linear_multiply(0.48), - ); + painter.circle_filled(egui::pos2(x + jx, y + jy), 1.2 * p, color.linear_multiply(0.48)); } } } hcie_engine_api::tools::BrushStyle::Hatch => { - // Cross hatches for (i, &(x, y, t)) in points.iter().enumerate() { - if i % 4 != 0 { - continue; - } + if i % 4 != 0 { continue; } let p = (t * std::f32::consts::PI).sin(); - let size = 4.2 * p; + let sz = 4.2 * p; painter.line_segment( - [ - egui::pos2(x - size, y - size), - egui::pos2(x + size, y + size), - ], + [egui::pos2(x - sz, y - sz), egui::pos2(x + sz, y + sz)], egui::Stroke::new(0.9, color), ); } } hcie_engine_api::tools::BrushStyle::WetPaint => { - // Wet paint drops for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -524,16 +510,11 @@ pub fn draw_brush_preview( egui::Stroke::new(6.0 * p, color), ); if i % 10 == 0 && i > 0 { - painter.circle_filled( - egui::pos2(x1, y1 + 4.2 * p), - 2.2 * p, - color.linear_multiply(0.78), - ); + painter.circle_filled(egui::pos2(x1, y1 + 4.2 * p), 2.2 * p, color.linear_multiply(0.78)); } } } hcie_engine_api::tools::BrushStyle::InkPen => { - // Crisp ink pen for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -545,18 +526,17 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Marker => { - // Angled wide highlighting tape for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); let w = 5.2 * p; - let marker_col = color.linear_multiply(0.42); - let p1 = egui::pos2(x - w, y + w * 1.8); - let p2 = egui::pos2(x + w, y - w * 1.8); - painter.line_segment([p1, p2], egui::Stroke::new(3.8, marker_col)); + let mc = color.linear_multiply(0.42); + painter.line_segment( + [egui::pos2(x - w, y + w * 1.8), egui::pos2(x + w, y - w * 1.8)], + egui::Stroke::new(3.8, mc), + ); } } hcie_engine_api::tools::BrushStyle::Sketch => { - // Sketch overlapping lines for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -574,46 +554,27 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Star => { - // Tiny star sparkles for (i, &(x, y, t)) in points.iter().enumerate() { - if i % 6 != 0 { - continue; - } + if i % 6 != 0 { continue; } let p = (t * std::f32::consts::PI).sin(); let r = 4.2 * p; - if r < 1.0 { - continue; - } - painter.line_segment( - [egui::pos2(x - r, y), egui::pos2(x + r, y)], - egui::Stroke::new(1.1, color), - ); - painter.line_segment( - [egui::pos2(x, y - r), egui::pos2(x, y + r)], - egui::Stroke::new(1.1, color), - ); + if r < 1.0 { continue; } + painter.line_segment([egui::pos2(x - r, y), egui::pos2(x + r, y)], egui::Stroke::new(1.1, color)); + painter.line_segment([egui::pos2(x, y - r), egui::pos2(x, y + r)], egui::Stroke::new(1.1, color)); } } hcie_engine_api::tools::BrushStyle::Dirt => { - // Dusty dirt speckles for &(x, y, t) in &points { let p = (t * std::f32::consts::PI).sin(); - if p < 0.1 { - continue; - } + if p < 0.1 { continue; } for s in 0..2 { let ox = (x * 47.3 + s as f32 * 11.0).sin() * 6.0 * p; let oy = (y * 53.2 + s as f32 * 17.0).cos() * 6.0 * p; - painter.circle_filled( - egui::pos2(x + ox, y + oy), - 0.7, - color.linear_multiply(0.4), - ); + painter.circle_filled(egui::pos2(x + ox, y + oy), 0.7, color.linear_multiply(0.4)); } } } hcie_engine_api::tools::BrushStyle::Mixer | hcie_engine_api::tools::BrushStyle::Blender => { - // Dual gradient smudge ribbon for i in 0..n_points { let (x1, y1, t1) = points[i]; let (x2, y2, _t2) = points[i + 1]; @@ -626,22 +587,69 @@ pub fn draw_brush_preview( } } hcie_engine_api::tools::BrushStyle::Bitmap => { - // Show a simple stroke preview for imported bitmap brushes - for i in 0..n_points { - let (x1, y1, t1) = points[i]; - let (x2, y2, _t2) = points[i + 1]; - let p = (t1 * std::f32::consts::PI).sin(); - let thickness = 1.0 + 5.0 * p; - painter.line_segment( - [egui::pos2(x1, y1), egui::pos2(x2, y2)], - egui::Stroke::new(thickness, color), - ); + if let Some(tip) = tip { + render_bitmap_thumbnail(painter, rect, tip, color); + } else { + for i in 0..n_points { + let (x1, y1, t1) = points[i]; + let (x2, y2, _t2) = points[i + 1]; + let p = (t1 * std::f32::consts::PI).sin(); + let thickness = 1.0 + 5.0 * p; + painter.line_segment( + [egui::pos2(x1, y1), egui::pos2(x2, y2)], + egui::Stroke::new(thickness, color), + ); + } } } } } -/// Convert standard lib-level `hcie_engine_api::BrushStyle` to tool-level `hcie_engine_api::tools::BrushStyle`. +fn render_bitmap_thumbnail( + painter: &egui::Painter, + rect: egui::Rect, + tip: &BrushTip, + color: egui::Color32, +) { + if tip.bitmap_pixels.is_empty() || tip.bitmap_w == 0 || tip.bitmap_h == 0 { + return; + } + let bw = tip.bitmap_w as f32; + let bh = tip.bitmap_h as f32; + let scale = (rect.width() / bw).min(rect.height() / bh); + let draw_w = bw * scale; + let draw_h = bh * scale; + let offset_x = (rect.width() - draw_w) * 0.5; + let offset_y = (rect.height() - draw_h) * 0.5; + + let pixel_size = scale.max(1.0); + let mut shapes = Vec::new(); + let mut y = 0.0; + let mut sy = 0u32; + while sy < tip.bitmap_h { + let mut x = 0.0; + let mut sx = 0u32; + while sx < tip.bitmap_w { + let mask_val = tip.bitmap_pixels[(sy * tip.bitmap_w + sx) as usize] as f32 / 255.0; + if mask_val > 0.01 { + let a = (mask_val * color.a() as f32).round() as u8; + let c = egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a); + let p = rect.min + egui::vec2(offset_x + x, offset_y + y); + shapes.push(egui::Shape::rect_filled( + egui::Rect::from_min_size(p, egui::vec2(pixel_size, pixel_size)), + 0.0, + c, + )); + } + x += pixel_size; + sx += 1; + } + y += pixel_size; + sy += 1; + } + painter.add(egui::Shape::Vec(shapes)); +} + pub fn to_tools_style(style: hcie_engine_api::BrushStyle) -> hcie_engine_api::tools::BrushStyle { match style { hcie_engine_api::BrushStyle::Round @@ -1006,8 +1014,8 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) { ui.add_space(6.0); // 2. Category Tab Filters - Oval Pills with 12px rounding - let categories = ["All", "Drawing", "Painting", "Effects", "Custom"]; - let category_map = |cat: &str| -> Vec<&'static str> { + let categories = ["All", "Drawing", "Painting", "Effects", "Custom", "Imported"]; + let _category_map = |cat: &str| -> Vec<&'static str> { match cat { "Drawing" => vec!["Basic", "Sketch"], "Painting" => vec!["Paint", "Ink"], @@ -1068,6 +1076,18 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) { app.show_brush_editor = true; app.brush_editor_tab = 0; } + let refresh_btn = ui.add( + egui::Button::new( + egui::RichText::new("Refresh") + .size(10.0) + .color(colors.text_secondary), + ) + .fill(egui::Color32::TRANSPARENT), + ); + if refresh_btn.clicked() { + state.brush_thumbnail_cache.clear(); + state.brush_thumbnail_dirty = true; + } }); // 4. Segmented View Toggle Control Block (Unified with flat inner borders and outer rounded corners) @@ -1238,12 +1258,20 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) { egui::Stroke::new(0.5, colors.border_low), egui::StrokeKind::Outside, ); - draw_brush_preview( - painter, - preview_rect, - to_tools_style(map_brush_style(preset.style)), - primary_srgba, - ); + let h = preset_thumb_hash(preset); + let cache_key = format!("{}.{}", preset.id, h); + if state.brush_thumbnail_dirty || !state.brush_thumbnail_cache.contains_key(&cache_key) { + let tex = render_thumb_to_texture(ui.ctx(), preset); + state.brush_thumbnail_cache.insert(cache_key.clone(), tex); + } + if let Some(tex) = state.brush_thumbnail_cache.get(&cache_key) { + painter.image( + tex.id(), + preview_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } // Swatch Label Details let name_pos = rect.left_top() + egui::vec2(86.0, 8.0); @@ -1411,12 +1439,20 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) { egui::Color32::from_gray(28) }; painter.rect_filled(preview_rect, egui::CornerRadius::same(2), bg_fill); - draw_brush_preview( - painter, - preview_rect, - to_tools_style(map_brush_style(preset.style)), - primary_srgba, - ); + let h = preset_thumb_hash(preset); + let cache_key = format!("{}.{}", preset.id, h); + if state.brush_thumbnail_dirty || !state.brush_thumbnail_cache.contains_key(&cache_key) { + let tex = render_thumb_to_texture(ui.ctx(), preset); + state.brush_thumbnail_cache.insert(cache_key.clone(), tex); + } + if let Some(tex) = state.brush_thumbnail_cache.get(&cache_key) { + painter.image( + tex.id(), + preview_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } // Name let name_pos = @@ -1620,7 +1656,7 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) { egui::Stroke::new(0.5, colors.border_low), egui::StrokeKind::Outside, ); - draw_brush_preview(painter, preview_rect, style, primary_srgba); + draw_brush_preview(painter, preview_rect, style, primary_srgba, None); let name_pos = egui::pos2(cell_rect.center().x, preview_rect.bottom() + 1.0); @@ -1695,4 +1731,7 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) { } }); }); + + // Clear dirty flag after all thumbnails have been rendered this frame + state.brush_thumbnail_dirty = false; } diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/utils.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/utils.rs new file mode 100644 index 0000000..1a301b2 --- /dev/null +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/utils.rs @@ -0,0 +1,273 @@ +//! Small, stateless helper functions used across the app. +//! +//! This module gathers image/color utilities, file-dialog builders, save-error +//! hints, and the flood-fill mask generator so the main app module stays +//! focused on orchestration. + +use eframe::egui; + +/// Format an RGBA color as a `#RRGGBB` hex string (alpha is ignored). +pub fn format_color(c: &[u8; 4]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + +/// **Purpose:** Draws a pixel-perfect, highly-refined minimalist pushpin vector icon. +/// +/// **Logic & Workflow:** +/// - If `pinned` is true, renders a vertical pushpin (cap, body, ridge, needle). +/// - If `pinned` is false, renders a slanted 45-degree pushpin pointing down-left. +pub fn draw_premium_pin( + painter: &egui::Painter, + center: egui::Pos2, + color: egui::Color32, + pinned: bool, +) { + if pinned { + // Vertical pin (pinned state) + let cap_rect = + egui::Rect::from_center_size(center - egui::vec2(0.0, 4.0), egui::vec2(8.0, 1.5)); + painter.rect_filled(cap_rect, 0.5, color); + + let body_rect = + egui::Rect::from_center_size(center - egui::vec2(0.0, 1.0), egui::vec2(5.0, 4.5)); + painter.rect_filled(body_rect, 0.5, color); + + let ridge_rect = + egui::Rect::from_center_size(center + egui::vec2(0.0, 1.75), egui::vec2(7.0, 1.0)); + painter.rect_filled(ridge_rect, 0.0, color); + + let needle_rect = + egui::Rect::from_center_size(center + egui::vec2(0.0, 4.75), egui::vec2(1.2, 5.0)); + painter.rect_filled(needle_rect, 0.0, color); + } else { + // Slanted pin (45 degrees pointing down-left, i.e., unpinned) + let dir = egui::vec2(-1.0, 1.0).normalized(); + let ortho = egui::vec2(1.0, 1.0).normalized(); + + painter.line_segment( + [center + dir * 0.5, center + dir * 5.5], + egui::Stroke::new(1.3, color), + ); + + let ridge_c = center - dir * 0.75; + painter.line_segment( + [ridge_c - ortho * 3.0, ridge_c + ortho * 3.0], + egui::Stroke::new(1.2, color), + ); + + let body_c = ridge_c - dir * 2.0; + let p1 = body_c - ortho * 2.0 - dir * 1.5; + let p2 = body_c + ortho * 2.0 - dir * 1.5; + let p3 = body_c + ortho * 2.0 + dir * 1.5; + let p4 = body_c - ortho * 2.0 + dir * 1.5; + painter.add(egui::Shape::convex_polygon( + vec![p1, p2, p3, p4], + color, + egui::Stroke::NONE, + )); + + let cap_c = ridge_c - dir * 4.25; + painter.line_segment( + [cap_c - ortho * 3.5, cap_c + ortho * 3.5], + egui::Stroke::new(1.5, color), + ); + } +} + +/// Crop an egui ColorImage to the given screen-space rectangle (rounded to pixels). +pub fn crop_colorimage(img: &egui::ColorImage, rect: egui::Rect) -> Option { + let x0 = rect.min.x.max(0.0).round() as usize; + let y0 = rect.min.y.max(0.0).round() as usize; + let x1 = (rect.max.x.min(img.width() as f32)).round() as usize; + let y1 = (rect.max.y.min(img.height() as f32)).round() as usize; + let w = x1.saturating_sub(x0); + let h = y1.saturating_sub(y0); + if w == 0 || h == 0 { + return None; + } + let mut bytes = Vec::with_capacity(w * h * 4); + for y in y0..y1 { + for x in x0..x1 { + let c = img[(x, y)]; + bytes.extend_from_slice(&c.to_array()); + } + } + Some(egui::ColorImage::from_rgba_unmultiplied([w, h], &bytes)) +} + +pub fn save_colorimage(path: &std::path::Path, img: &egui::ColorImage) -> Result<(), String> { + let mut bytes: Vec = Vec::with_capacity(img.width() * img.height() * 4); + for c in img.pixels.iter() { + bytes.extend_from_slice(&c.to_array()); + } + image::save_buffer( + path, + &bytes, + img.width() as u32, + img.height() as u32, + image::ColorType::Rgba8, + ) + .map_err(|e| e.to_string()) +} + +/// Builds a fallback save path when the user-selected location cannot be written. +/// +/// The suggestion tries the user's home directory first, then the system temp directory, +/// preserving the original file stem and extension. This gives the user a concrete, +/// writable alternative they can select instead of silently failing. +pub fn suggest_alternative_path(original: &std::path::Path) -> std::path::PathBuf { + let stem = original + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("untitled"); + let ext = original + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("hcie"); + let file_name = format!("{}_copy.{}", stem, ext); + + if let Some(home) = dirs::home_dir() { + let candidate = home.join(&file_name); + if candidate.parent().map(|p| p.exists()).unwrap_or(false) { + return candidate; + } + } + + std::env::temp_dir().join(file_name) +} + +/// Builds a Save/Export file dialog with the document's source format selected +/// by default. +/// +/// **Purpose:** Ensures the active file-type filter matches the document that +/// is being saved (e.g. KRA for a Krita file). If the format is unknown, the +/// helper still builds the dialog with HCIE Native listed first. An "All +/// Supported Types" filter is appended at the end so the user can pick any +/// supported format. +/// +/// **Arguments:** +/// * `default_format` - Lowercase extension to pre-select (`kra`, `hcie`, `png`, ...). +/// * `file_stem` - Base name for the suggested file. +/// * `is_export` - When `true`, appends `_export` to the file stem. +pub fn build_save_dialog(default_format: &str, file_stem: &str, is_export: bool) -> rfd::FileDialog { + let suffix = if is_export { "_export" } else { "" }; + let file_name = format!("{}{}.{}", file_stem, suffix, default_format); + + let all_supported: &[&str] = &[ + "png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", "avif", "ico", "pnm", "qoi", + "hdr", "dds", "tga", "exr", "psd", "kra", "hcie", + ]; + let filters: Vec<(&str, &[&str])> = vec![ + ("HCIE Native", &["hcie"]), + ("KRA", &["kra"]), + ("PSD", &["psd"]), + ("PNG", &["png"]), + ("JPEG", &["jpg", "jpeg"]), + ("WebP", &["webp"]), + ("AVIF", &["avif"]), + ("BMP", &["bmp"]), + ("GIF", &["gif"]), + ("TIFF", &["tiff", "tif"]), + ("ICO", &["ico"]), + ("PNM", &["pnm"]), + ("QOI", &["qoi"]), + ("HDR", &["hdr"]), + ("DDS", &["dds"]), + ("TGA", &["tga"]), + ("OpenEXR", &["exr"]), + ]; + + // Start with the format matching the document so it is the active filter. + let mut dialog = rfd::FileDialog::new().set_file_name(file_name); + for (name, exts) in &filters { + if exts.contains(&default_format) { + dialog = dialog.add_filter(*name, *exts); + break; + } + } + // Then add the remaining format filters. + for (name, exts) in &filters { + if !exts.contains(&default_format) { + dialog = dialog.add_filter(*name, *exts); + } + } + // Append a catch-all filter last. + dialog.add_filter("All Supported Types", all_supported) +} + +/// Formats a short, actionable error message for the save/export failure dialog. +/// +/// Detects common I/O errors (permission denied, not found, disk full) and adds a localized +/// hint so the user understands why the operation failed and what to try next. +pub fn format_save_error_hint(error: &str) -> &'static str { + let lower = error.to_lowercase(); + if lower.contains("permission denied") || lower.contains("os error 13") { + "The selected folder is read-only or you don't have permission to write there." + } else if lower.contains("not found") || lower.contains("os error 2") { + "The parent folder no longer exists." + } else if lower.contains("no space") || lower.contains("os error 28") { + "The destination disk is full." + } else { + "Check that the destination folder exists and is writable." + } +} + +/// Flood fill on composite RGBA pixels — works across all layers. +/// Returns a `w*h` byte mask where 255 = selected, 0 = not selected. +pub fn composite_flood_fill( + pixels: &[u8], + w: u32, + h: u32, + sx: u32, + sy: u32, + tolerance: i32, +) -> Vec { + let (w, h) = (w as usize, h as usize); + let mut mask = vec![0u8; w * h]; + if sx as usize >= w || sy as usize >= h || pixels.len() < w * h * 4 { + return mask; + } + + let seed = (sy as usize * w + sx as usize) * 4; + let sr = pixels[seed] as i32; + let sg = pixels[seed + 1] as i32; + let sb = pixels[seed + 2] as i32; + let sa = pixels[seed + 3] as i32; + + let mut queue = std::collections::VecDeque::new(); + queue.push_back((sx as usize, sy as usize)); + mask[sy as usize * w + sx as usize] = 255; + let mut count = 0usize; + + while let Some((cx, cy)) = queue.pop_front() { + count += 1; + if count > 500_000 { + break; + } + + for (dx, dy) in [(0i32, 1i32), (0, -1), (1, 0), (-1, 0)] { + let nx = cx as i32 + dx; + let ny = cy as i32 + dy; + if nx < 0 || nx >= w as i32 || ny < 0 || ny >= h as i32 { + continue; + } + let (nx, ny) = (nx as usize, ny as usize); + let ni = ny * w + nx; + if mask[ni] != 0 { + continue; + } + + let pi = ni * 4; + let diff = (pixels[pi] as i32 - sr).abs() + + (pixels[pi + 1] as i32 - sg).abs() + + (pixels[pi + 2] as i32 - sb).abs() + + (pixels[pi + 3] as i32 - sa).abs(); + + if diff <= tolerance { + mask[ni] = 255; + queue.push_back((nx, ny)); + } + } + } + mask +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/brush_import.rs b/hcie-egui-app/crates/hcie-gui-egui/src/brush_import.rs index 567504c..b80c274 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/brush_import.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/brush_import.rs @@ -3,6 +3,11 @@ use hcie_engine_api::brush::{BrushStyle, BrushTip}; use hcie_engine_api::BrushPreset; use std::io::{Cursor, Read, Seek, SeekFrom}; +/// Parse an ABR brush file and return a list of `BrushPreset` entries. +/// +/// The ABR format stores sampled bitmap brushes in a `samp` resource block and +/// parametric/computed brushes in `computedB` markers. Brush names are extracted +/// from the `desc` block (which contains `Nm TEXT` tags) when available. pub fn import_abr(data: &[u8]) -> Vec { let mut presets = Vec::new(); let mut cursor = Cursor::new(data); @@ -26,6 +31,12 @@ pub fn import_abr(data: &[u8]) -> Vec { if end > start && end <= data.len() { presets.extend(harvest_sampled_brushes(&data[start..end], start)); } + } else if tag == "Brs2" || tag == "brst" { + let start = cursor.position() as usize; + let end = block_end as usize; + if end > start && end <= data.len() { + presets.extend(harvest_brs2_sampled_brushes(&data[start..end], start)); + } } pos = (block_end as usize + 1) & !1; } else { @@ -34,6 +45,10 @@ pub fn import_abr(data: &[u8]) -> Vec { } presets.extend(harvest_computed_brushes(data)); + + let desc_names = extract_desc_block_names(data); + apply_desc_names(&mut presets, &desc_names); + presets } @@ -133,10 +148,16 @@ fn try_decode_and_scale( dst_w: u32, dst_h: u32, ) -> Option> { - let decoded = decode_packbits_lenient(data, src_w * src_h); - if decoded.len() != src_w * src_h { + // Some ABR files store the width as (right - left) but the PackBits stream + // contains one extra padding column/row. Accept decoded data that is at least + // src_w * src_h and truncate any surplus instead of rejecting the brush. + let mut decoded = decode_packbits_lenient(data, src_w * src_h); + if decoded.len() < src_w * src_h { return None; } + if decoded.len() > src_w * src_h { + decoded.truncate(src_w * src_h); + } if src_w as u32 == dst_w && src_h as u32 == dst_h { return Some(decoded); } @@ -191,6 +212,86 @@ fn decode_packbits_lenient(data: &[u8], expected_size: usize) -> Vec { out } +/// Heuristic extraction for sampled brushes embedded in a `Brs2` / `brst` +/// resource block. Older ABR versions lay out the data as: +/// [count u32] for each brush [l t r b i32] [depth?] [name pascal] +/// [encoding byte?] [PackBits image data] +/// This is intentionally lenient so we do not crash on partially unknown formats. +fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec { + let mut presets = Vec::new(); + if data.len() < 4 { + return presets; + } + let count = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize; + if count == 0 || count > 1000 { + return presets; + } + let mut i = 4usize; + for _ in 0..count { + if i + 16 > data.len() { + break; + } + let l = i32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]); + let t = i32::from_be_bytes([data[i + 4], data[i + 5], data[i + 6], data[i + 7]]); + let r = i32::from_be_bytes([data[i + 8], data[i + 9], data[i + 10], data[i + 11]]); + let b = i32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]); + i += 16; + let w = (r as i64 - l as i64).abs() as u32; + let h = (b as i64 - t as i64).abs() as u32; + if w == 0 || h == 0 || w > 10000 || h > 10000 { + continue; + } + // Skip depth/name/etc until we find an encoding signature or raw data. + // Try to locate a PackBits-like run near expected data offset. + let data_start = i; + let data_end = (i + (w * h * 2) as usize + 1024).min(data.len()); + let slice = &data[data_start..data_end]; + let (final_w, final_h) = if w > 1024 || h > 1024 { + let scale = 1024.0 / (w.max(h) as f32); + ((w as f32 * scale) as u32, (h as f32 * scale) as u32) + } else { + (w, h) + }; + if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h) { + presets.push(BrushPreset { + id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h), + name: format!("Brush {}x{}", final_w, final_h), + category: "Imported (ABR)".to_string(), + tip: BrushTip { + style: BrushStyle::Bitmap, + size: final_w.max(final_h) as f32, + opacity: 1.0, + hardness: 1.0, + spacing: 0.1, + jitter_amount: 0.0, + scatter_amount: 0.0, + angle: 0.0, + roundness: 1.0, + spray_particle_size: 2.0, + spray_density: 100, + bitmap_pixels: decoded, + bitmap_w: final_w, + bitmap_h: final_h, + flow: 1.0, + color_variant: false, + variant_amount: 0.0, + density: 1.0, + drawing_angle: false, + rotation_random: 0.0, + }, + style: BrushStyle::Bitmap, + pressure_size: true, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }); + } + i = data_end; + } + presets +} + fn harvest_computed_brushes(data: &[u8]) -> Vec { let mut presets = Vec::new(); let marker = b"computedB"; @@ -304,6 +405,227 @@ fn read_u32(cursor: &mut Cursor<&[u8]>) -> u32 { u32::from_be_bytes(buf) } -pub fn import_krita_preset(_data: &[u8]) -> Option { - None +/// Extract brush names from the `desc` block's `Nm TEXT` tags. +/// +/// ABR files store a `desc` resource block that contains `BrshVlLs` (Brush +/// Value Lists). Each entry has a `Nm TEXT` field with the brush name in +/// UTF-16 encoding. The names appear in the same order as the brushes in the +/// `samp` block, so they can be associated by index. +fn extract_desc_block_names(data: &[u8]) -> Vec { + let mut names = Vec::new(); + let desc_pos = match find_bytes(data, b"8BIMdesc") { + Some(p) => p, + None => return names, + }; + let size_pos = desc_pos + 8; + if size_pos + 4 > data.len() { + return names; + } + let size = u32::from_be_bytes([ + data[size_pos], + data[size_pos + 1], + data[size_pos + 2], + data[size_pos + 3], + ]) as usize; + let block_start = size_pos + 4; + let block_end = (block_start + size).min(data.len()); + if block_end <= block_start { + return names; + } + let block = &data[block_start..block_end]; + + let mut idx = 0; + while idx < block.len() - 8 { + if &block[idx..idx + 8] == b"Nm TEXT" { + let p = idx + 8; + if p + 4 > block.len() { + break; + } + let nchars = u32::from_be_bytes([block[p], block[p + 1], block[p + 2], block[p + 3]]) as usize; + let text_start = p + 4; + if nchars == 0 || text_start + nchars * 2 > block.len() { + idx += 1; + continue; + } + let chars: Vec = block[text_start..text_start + nchars * 2] + .chunks_exact(2) + .map(|b| u16::from_be_bytes([b[0], b[1]])) + .collect(); + let full = String::from_utf16_lossy(&chars); + let name = full + .split('=') + .last() + .unwrap_or(&full) + .trim_matches('\x00') + .to_string(); + if !name.is_empty() { + names.push(name); + } + idx = text_start + nchars * 2; + } else { + idx += 1; + } + } + names +} + +/// Apply names extracted from the `desc` block to presets. +/// +/// The desc block names appear in the same order as sampled brushes were +/// collected. Computed brushes already have their names from `Nm TEXT` tags +/// inside the computed brush data, so only sampled presets (Bitmap style) +/// get updated. +fn apply_desc_names(presets: &mut [BrushPreset], desc_names: &[String]) { + if desc_names.is_empty() { + return; + } + let mut name_idx = 0; + for preset in presets.iter_mut() { + if preset.style == BrushStyle::Bitmap && name_idx < desc_names.len() { + let new_name = &desc_names[name_idx]; + if !new_name.is_empty() { + preset.name = new_name.clone(); + } + name_idx += 1; + } + } +} + +/// Parse an ABR file and prefix generated IDs/names so imports from different +/// files do not collide. +pub fn import_abr_with_prefix(data: &[u8], prefix: &str) -> Vec { + let mut presets = import_abr(data); + let safe_prefix = prefix.replace(' ', "_").replace('.', "_"); + for preset in &mut presets { + preset.id = format!("{}_{}", safe_prefix, preset.id); + preset.name = format!("{} ({})", preset.name, safe_prefix); + } + presets +} + +/// Parse a Krita brush preset (`.kpp`). +/// +/// A `.kpp` file is a ZIP archive. This implementation extracts the `preview.png` +/// thumbnail, converts it to a grayscale alpha mask, and returns a single +/// `BrushStyle::Bitmap` preset. +pub fn import_kpp(data: &[u8], name_hint: &str) -> Option { + use std::io::Cursor; + use zip::ZipArchive; + + let cursor = Cursor::new(data); + let mut zip = ZipArchive::new(cursor).ok()?; + let mut preview_bytes: Option> = None; + for i in 0..zip.len() { + let mut entry = zip.by_index(i).ok()?; + let entry_name = entry.name().to_lowercase(); + if entry_name.ends_with("preview.png") || entry_name == "preview.png" { + let mut buf = Vec::new(); + if entry.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + preview_bytes = Some(buf); + break; + } + } + } + let preview_bytes = preview_bytes?; + let img = image::load_from_memory(&preview_bytes).ok()?; + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + if w == 0 || h == 0 { + return None; + } + // Downscale huge previews so GPU brush stamps stay cheap. + let (final_w, final_h) = if w > 512 || h > 512 { + let scale = 512.0 / (w.max(h) as f32); + ((w as f32 * scale) as u32, (h as f32 * scale) as u32) + } else { + (w, h) + }; + let mut gray = Vec::with_capacity((final_w * final_h) as usize); + for y in 0..final_h { + for x in 0..final_w { + let src_x = (x as f32 * (w as f32 / final_w as f32)) as u32; + let src_y = (y as f32 * (h as f32 / final_h as f32)) as u32; + let px = rgba.get_pixel(src_x.min(w - 1), src_y.min(h - 1)); + // Use perceived luminance from RGB and keep full alpha range. + let l = (0.299 * px[0] as f32 + 0.587 * px[1] as f32 + 0.114 * px[2] as f32) + .clamp(0.0, 255.0) as u8; + let a = px[3]; + // Mask value = luminance scaled by alpha. + gray.push((l as u16 * a as u16 / 255) as u8); + } + } + Some(BrushPreset { + id: format!("kpp_imported_{}_{}x{}", name_hint.replace(' ', "_"), final_w, final_h), + name: format!("{} (KPP)", name_hint), + category: "Imported".to_string(), + tip: BrushTip { + style: BrushStyle::Bitmap, + size: final_w.max(final_h) as f32, + opacity: 1.0, + hardness: 1.0, + spacing: 0.1, + jitter_amount: 0.0, + scatter_amount: 0.0, + angle: 0.0, + roundness: 1.0, + spray_particle_size: 2.0, + spray_density: 100, + bitmap_pixels: gray, + bitmap_w: final_w, + bitmap_h: final_h, + flow: 1.0, + color_variant: false, + variant_amount: 0.0, + density: 1.0, + drawing_angle: false, + rotation_random: 0.0, + }, + style: BrushStyle::Bitmap, + pressure_size: true, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_desc_block_names() { + let path = "/mnt/data/Downloads/Square Brushes.abr"; + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => { + eprintln!("Skipping test: {} not found", path); + return; + } + }; + let names = extract_desc_block_names(&data); + assert!(!names.is_empty(), "Should extract names from desc block"); + assert!( + names[0].contains("Hard Square"), + "First name should be descriptive, got: {}", + names[0] + ); + } + + #[test] + fn test_abr_import_with_names() { + let path = "/mnt/data/Downloads/Square Brushes.abr"; + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => { + eprintln!("Skipping test: {} not found", path); + return; + } + }; + let presets = import_abr(&data); + let bitmap: Vec<_> = presets.iter().filter(|p| p.style == BrushStyle::Bitmap).collect(); + assert!(!bitmap.is_empty(), "Should have bitmap presets"); + let has_names = bitmap.iter().any(|p| !p.name.starts_with("Brush ")); + assert!(has_names, "Bitmap presets should have descriptive names"); + } } diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs index bf73cc7..9af23f1 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs @@ -4,6 +4,7 @@ use crate::app::{tools::shape_sync, AppDocument, SelectionTransform, ToolState, use crate::event_bus::{AppEvent, EventBus}; use eframe::egui; use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN}; +use hcie_engine_api::brush::BrushStyle; pub mod render; pub mod text_overlay; @@ -1433,18 +1434,84 @@ impl<'a> egui::Widget for CanvasWidget<'a> { if self.state.active_tool.shows_pen_tips() { let screen_x = canvas_rect.min.x + cx as f32 * zoom; let screen_y = canvas_rect.min.y + cy as f32 * zoom; - let radius = self.state.active_size() * zoom * 0.5; let color = egui::Color32::from_rgba_unmultiplied( self.state.primary_color[0], self.state.primary_color[1], self.state.primary_color[2], 180, ); - painter.circle_stroke( - egui::pos2(screen_x, screen_y), - radius, - egui::Stroke::new(1.5, color), - ); + let active_tip = self + .state + .brush_presets + .iter() + .find(|p| p.id == self.state.active_brush_preset_id) + .map(|p| &p.tip); + if let Some(tip) = active_tip { + if tip.style == BrushStyle::Bitmap + && !tip.bitmap_pixels.is_empty() + && tip.bitmap_w > 0 + && tip.bitmap_h > 0 + { + let bw = tip.bitmap_w as f32; + let bh = tip.bitmap_h as f32; + let stamp_size = self.state.active_size() * zoom; + let scale = (stamp_size / bw).min(stamp_size / bh); + let draw_w = bw * scale; + let draw_h = bh * scale; + let origin_x = screen_x - draw_w * 0.5; + let origin_y = screen_y - draw_h * 0.5; + let px_size = scale.max(1.0); + let mut shapes = Vec::new(); + let mut py = 0.0; + let mut sy = 0u32; + while sy < tip.bitmap_h { + let mut px = 0.0; + let mut sx = 0u32; + while sx < tip.bitmap_w { + let mask = + tip.bitmap_pixels[(sy * tip.bitmap_w + sx) as usize] as f32 + / 255.0; + if mask > 0.01 { + let a = (mask * 180.0).round() as u8; + let c = egui::Color32::from_rgba_unmultiplied( + color.r(), + color.g(), + color.b(), + a, + ); + let p = egui::pos2(origin_x + px, origin_y + py); + shapes.push(egui::Shape::rect_filled( + egui::Rect::from_min_size( + p, + egui::vec2(px_size, px_size), + ), + 0.0, + c, + )); + } + px += px_size; + sx += 1; + } + py += px_size; + sy += 1; + } + painter.add(egui::Shape::Vec(shapes)); + } else { + let radius = self.state.active_size() * zoom * 0.5; + painter.circle_stroke( + egui::pos2(screen_x, screen_y), + radius, + egui::Stroke::new(1.5, color), + ); + } + } else { + let radius = self.state.active_size() * zoom * 0.5; + painter.circle_stroke( + egui::pos2(screen_x, screen_y), + radius, + egui::Stroke::new(1.5, color), + ); + } } } diff --git a/hcie-engine-api/src/dynamic_loader.rs b/hcie-engine-api/src/dynamic_loader.rs index cd642b9..c7380db 100644 --- a/hcie-engine-api/src/dynamic_loader.rs +++ b/hcie-engine-api/src/dynamic_loader.rs @@ -9,56 +9,8 @@ use libloading::Library; /// 0 = CPU, 1 = GPU. Stored here so it can be set before the plugin is loaded. static VISION_BACKEND: AtomicU8 = AtomicU8::new(0); -pub fn blend_pixels(dst: [u8; 4], src: [u8; 4], mode: hcie_protocol::BlendMode, opacity: f32) -> [u8; 4] { - hcie_blend::blend_pixels(dst, src, mode.into(), opacity) -} -pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: hcie_protocol::BlendMode, opacity: f32) { - hcie_blend::blend_buffers(dst, src, mode.into(), opacity) -} - -pub fn apply_filter(filter_id: &str, params: &serde_json::Value, pixels: &mut [u8], width: u32, height: u32) -> Result<(), String> { - let mut fl = hcie_protocol::Layer::new_transparent("", width, height); - fl.pixels.copy_from_slice(pixels); - hcie_filter::apply_filter(&mut fl, filter_id, params); - let len = pixels.len().min(fl.pixels.len()); - pixels[..len].copy_from_slice(&fl.pixels[..len]); - Ok(()) -} - -pub fn draw_line(layer: &mut hcie_protocol::Layer, x0: f32, y0: f32, x1: f32, y1: f32, color: [u8; 4], stroke_size: f32, mask: Option<&[u8]>) { - hcie_draw::draw_line(layer, x0, y0, x1, y1, color, stroke_size, mask); layer.dirty = true; -} -pub fn draw_filled_rect(layer: &mut hcie_protocol::Layer, x1: f32, y1: f32, x2: f32, y2: f32, color: [u8; 4], mask: Option<&[u8]>) { - hcie_draw::draw_filled_rect(layer, x1, y1, x2, y2, color, mask); layer.dirty = true; -} -pub fn draw_filled_ellipse(layer: &mut hcie_protocol::Layer, cx: f32, cy: f32, rx: f32, ry: f32, color: [u8; 4], mask: Option<&[u8]>) { - hcie_draw::draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask); layer.dirty = true; -} -pub fn flood_fill(layer: &mut hcie_protocol::Layer, x: u32, y: u32, color: [u8; 4], tolerance: u8, mask: Option<&[u8]>) { - hcie_draw::flood_fill(layer, x, y, color, tolerance, mask); layer.dirty = true; -} -pub fn draw_brush_stroke(layer: &mut hcie_protocol::Layer, points: &[(f32, f32, f32)], color: [u8; 4], tip: &hcie_protocol::BrushTip, is_eraser: bool, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>) { - let style = if tip.style == hcie_protocol::BrushStyle::Default { - hcie_brush_engine::BrushStyle::Round - } else { - unsafe { std::mem::transmute::(tip.style as i32) } - }; - let ct = hcie_brush_engine::BrushTip { - style, - size: tip.size, opacity: tip.opacity, hardness: tip.hardness, - spacing: tip.spacing, flow: tip.flow, - jitter_amount: tip.jitter_amount, scatter_amount: tip.scatter_amount, - angle: tip.angle, roundness: tip.roundness, - spray_particle_size: tip.spray_particle_size, spray_density: tip.spray_density, - bitmap_pixels: tip.bitmap_pixels.clone(), bitmap_w: tip.bitmap_w, bitmap_h: tip.bitmap_h, - color_variant: tip.color_variant, variant_amount: tip.variant_amount, density: tip.density, - drawing_angle: false, - rotation_random: 0.0, - }; - hcie_draw::draw_brush_stroke(layer, points, color, &ct, is_eraser, mask, stroke_mask, bg_pixels); layer.dirty = true; -} - pub fn draw_specialized_stroke(pixels: &mut [u8], width: u32, height: u32, points: &[(f32, f32, f32)], brush_style: hcie_protocol::BrushStyle, size: f32, hardness: f32, color: [u8; 4], opacity: f32, spacing_ratio: f32, is_eraser: bool, sketch_history: Option<&mut Vec<(f32, f32)>>, spray_particle_size: f32, spray_density: u32, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32) { - let style = match brush_style { +fn map_brush_style(style: hcie_protocol::BrushStyle) -> hcie_brush_engine::BrushStyle { + match style { hcie_protocol::BrushStyle::Round => hcie_brush_engine::BrushStyle::Round, hcie_protocol::BrushStyle::Square => hcie_brush_engine::BrushStyle::Square, hcie_protocol::BrushStyle::HardRound => hcie_brush_engine::BrushStyle::HardRound, @@ -93,7 +45,56 @@ pub fn draw_brush_stroke(layer: &mut hcie_protocol::Layer, points: &[(f32, f32, hcie_protocol::BrushStyle::Blender => hcie_brush_engine::BrushStyle::Blender, hcie_protocol::BrushStyle::Default => hcie_brush_engine::BrushStyle::Round, hcie_protocol::BrushStyle::Bitmap => hcie_brush_engine::BrushStyle::Bitmap, + } +} + +pub fn blend_pixels(dst: [u8; 4], src: [u8; 4], mode: hcie_protocol::BlendMode, opacity: f32) -> [u8; 4] { + + hcie_blend::blend_pixels(dst, src, mode.into(), opacity) +} +pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: hcie_protocol::BlendMode, opacity: f32) { + hcie_blend::blend_buffers(dst, src, mode.into(), opacity) +} + +pub fn apply_filter(filter_id: &str, params: &serde_json::Value, pixels: &mut [u8], width: u32, height: u32) -> Result<(), String> { + let mut fl = hcie_protocol::Layer::new_transparent("", width, height); + fl.pixels.copy_from_slice(pixels); + hcie_filter::apply_filter(&mut fl, filter_id, params); + let len = pixels.len().min(fl.pixels.len()); + pixels[..len].copy_from_slice(&fl.pixels[..len]); + Ok(()) +} + +pub fn draw_line(layer: &mut hcie_protocol::Layer, x0: f32, y0: f32, x1: f32, y1: f32, color: [u8; 4], stroke_size: f32, mask: Option<&[u8]>) { + hcie_draw::draw_line(layer, x0, y0, x1, y1, color, stroke_size, mask); layer.dirty = true; +} +pub fn draw_filled_rect(layer: &mut hcie_protocol::Layer, x1: f32, y1: f32, x2: f32, y2: f32, color: [u8; 4], mask: Option<&[u8]>) { + hcie_draw::draw_filled_rect(layer, x1, y1, x2, y2, color, mask); layer.dirty = true; +} +pub fn draw_filled_ellipse(layer: &mut hcie_protocol::Layer, cx: f32, cy: f32, rx: f32, ry: f32, color: [u8; 4], mask: Option<&[u8]>) { + hcie_draw::draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask); layer.dirty = true; +} +pub fn flood_fill(layer: &mut hcie_protocol::Layer, x: u32, y: u32, color: [u8; 4], tolerance: u8, mask: Option<&[u8]>) { + hcie_draw::flood_fill(layer, x, y, color, tolerance, mask); layer.dirty = true; +} +pub fn draw_brush_stroke(layer: &mut hcie_protocol::Layer, points: &[(f32, f32, f32)], color: [u8; 4], tip: &hcie_protocol::BrushTip, is_eraser: bool, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>) { + let style = map_brush_style(tip.style); + let ct = hcie_brush_engine::BrushTip { + style, + size: tip.size, opacity: tip.opacity, hardness: tip.hardness, + spacing: tip.spacing, flow: tip.flow, + jitter_amount: tip.jitter_amount, scatter_amount: tip.scatter_amount, + angle: tip.angle, roundness: tip.roundness, + spray_particle_size: tip.spray_particle_size, spray_density: tip.spray_density, + bitmap_pixels: tip.bitmap_pixels.clone(), bitmap_w: tip.bitmap_w, bitmap_h: tip.bitmap_h, + color_variant: tip.color_variant, variant_amount: tip.variant_amount, density: tip.density, + drawing_angle: false, + rotation_random: 0.0, }; + hcie_draw::draw_brush_stroke(layer, points, color, &ct, is_eraser, mask, stroke_mask, bg_pixels); layer.dirty = true; +} + pub fn draw_specialized_stroke(pixels: &mut [u8], width: u32, height: u32, points: &[(f32, f32, f32)], brush_style: hcie_protocol::BrushStyle, size: f32, hardness: f32, color: [u8; 4], opacity: f32, spacing_ratio: f32, is_eraser: bool, sketch_history: Option<&mut Vec<(f32, f32)>>, spray_particle_size: f32, spray_density: u32, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32) { + let style = map_brush_style(brush_style); hcie_brush_engine::draw_specialized_stroke(pixels, width, height, points, style, size, hardness, color, opacity, spacing_ratio, is_eraser, sketch_history, spray_particle_size, spray_density, mask, stroke_mask, bg_pixels, color_variant, variant_amount, density) } diff --git a/hcie-engine-api/tests/bitmap_brush_stamp.rs b/hcie-engine-api/tests/bitmap_brush_stamp.rs new file mode 100644 index 0000000..c9de09c --- /dev/null +++ b/hcie-engine-api/tests/bitmap_brush_stamp.rs @@ -0,0 +1,49 @@ +use hcie_engine_api::{Engine, BrushTip, BrushStyle}; + +#[test] +fn bitmap_brush_stamp_shape() { + let mut engine = Engine::new(64, 64); + // 4x4 solid white square bitmap stamp + let mut pixels = vec![0u8; 16]; + for i in 0..16 { pixels[i] = 255; } + let brush = BrushTip { + style: BrushStyle::Bitmap, + size: 8.0, + opacity: 1.0, + hardness: 1.0, + spacing: 1.0, + bitmap_pixels: pixels, + bitmap_w: 4, + bitmap_h: 4, + ..Default::default() + }; + engine.set_brush_tip(brush); + engine.begin_stroke(engine.active_layer_id(), 20.0, 20.0); + engine.stroke_to(engine.active_layer_id(), 20.0, 20.0, 1.0); + engine.end_stroke(engine.active_layer_id()); + let output = engine.get_composite_pixels(); + + // Check that the painted area is roughly 4x4, not a circular blob. + // Count alpha > 0 pixels and bounding box. + let mut min_x = 63; let mut max_x = 0; let mut min_y = 63; let mut max_y = 0; + let mut count = 0; + for y in 0..64 { + for x in 0..64 { + let a = output[(y*64+x)*4+3]; + if a > 0 { + count += 1; + if x < min_x { min_x = x; } + if x > max_x { max_x = x; } + if y < min_y { min_y = y; } + if y > max_y { max_y = y; } + } + } + } + let w = (max_x - min_x + 1) as i32; + let h = (max_y - min_y + 1) as i32; + // Should fit inside ~8x8 because size=8 and stamp 4x4 scaled up + assert!(w <= 8 && h <= 8, "bitmap dab bounding box {}x{} too large for 4x4 stamp", w, h); + assert!(count >= 8, "bitmap dab should paint some pixels"); + // Square-ish: aspect ratio not too far from 1 (was very low for circle vs square?) + assert!(w.abs_diff(h) <= 2, "bitmap dab bounding box should be roughly square, got {}x{}", w, h); +}