From 9388e6f0967055039e7ac14151130678773e54e1 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Sun, 19 Jul 2026 00:55:40 +0300 Subject: [PATCH] GOOD Refactor GUI components and add PlainSlider widget - Added a new `PlainSlider` widget for keyboard-editable numeric sliders. - Updated various panels to utilize the new `PlainSlider` for better user interaction. - Removed unused code and dead code warnings across multiple files. - Improved organization of modules by adding a `widgets` module. - Cleaned up imports in several files to streamline the codebase. - Added Qodana configuration file for code analysis and quality checks. --- Cargo.lock | 46 +- Cargo.toml | 2 +- hcie-composite/src/parallel.rs | 1 - hcie-composite/src/tiled.rs | 1 - .../hcie-gui-egui/src/app/dock/egui_dock.rs | 35 +- .../crates/hcie-gui-egui/src/app/mod.rs | 68 +- .../crates/hcie-gui-egui/src/app/theme.rs | 35 +- .../src/app/tools/brushes_panel.rs | 1233 +++++++++-------- .../src/app/tools/layer_styles_panel.rs | 77 +- .../crates/hcie-gui-egui/src/app/utils.rs | 6 +- .../crates/hcie-gui-egui/src/brush_import.rs | 18 +- .../crates/hcie-gui-egui/src/canvas/mod.rs | 158 ++- .../crates/hcie-gui-egui/src/canvas/render.rs | 11 +- hcie-engine-api/src/lib.rs | 2 + .../crates/hcie-iced-gui/src/ai_chat.rs | 12 +- hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 74 +- .../crates/hcie-iced-gui/src/brush_import.rs | 2 +- .../crates/hcie-iced-gui/src/canvas/mod.rs | 76 +- .../crates/hcie-iced-gui/src/color_picker.rs | 4 + .../crates/hcie-iced-gui/src/dock/manager.rs | 1 + .../crates/hcie-iced-gui/src/dock/state.rs | 1 + .../crates/hcie-iced-gui/src/i18n.rs | 1 + hcie-iced-app/crates/hcie-iced-gui/src/lib.rs | 1 + .../crates/hcie-iced-gui/src/main.rs | 1 + .../hcie-iced-gui/src/panels/filter_params.rs | 2 +- .../hcie-iced-gui/src/panels/history.rs | 2 +- .../crates/hcie-iced-gui/src/panels/menus.rs | 1 + .../hcie-iced-gui/src/panels/properties.rs | 249 ++-- .../crates/hcie-iced-gui/src/panels/styles.rs | 1 + .../hcie-iced-gui/src/panels/title_bar.rs | 6 +- .../hcie-iced-gui/src/panels/tool_options.rs | 10 +- .../hcie-iced-gui/src/panels/tool_settings.rs | 6 +- .../hcie-iced-gui/src/panels/toolbar.rs | 1 + .../crates/hcie-iced-gui/src/script/mod.rs | 6 - .../hcie-iced-gui/src/selection/clipboard.rs | 1 + .../hcie-iced-gui/src/selection/transform.rs | 1 + .../crates/hcie-iced-gui/src/sidebar/mod.rs | 1 + .../crates/hcie-iced-gui/src/theme.rs | 1 + .../crates/hcie-iced-gui/src/viewer/mod.rs | 1 + .../src/viewer/thumbnail_grid.rs | 1 + .../crates/hcie-iced-gui/src/widgets/mod.rs | 10 + .../hcie-iced-gui/src/widgets/plain_slider.rs | 334 +++++ hcie-io/examples/analyze_bevel_emboss.rs | 2 +- hcie-io/examples/inspect_diff.rs | 2 - .../tune_mae_bevel_emboss_kra_grid.rs | 2 +- qodana.yaml | 46 + 46 files changed, 1680 insertions(+), 872 deletions(-) create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/widgets/mod.rs create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs create mode 100644 qodana.yaml diff --git a/Cargo.lock b/Cargo.lock index 817cf88..643369f 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,6 +69,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "aligned" version = "0.4.3" @@ -1333,7 +1339,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "proc-macro2-diagnostics", ] @@ -2884,6 +2890,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -3226,6 +3238,7 @@ dependencies = [ "iced_runtime", "num-traits", "once_cell", + "ouroboros", "rustc-hash 2.1.2", "thiserror 1.0.69", "unicode-segmentation", @@ -4614,6 +4627,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.118", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -4989,6 +5026,7 @@ dependencies = [ "quote", "syn 2.0.118", "version_check", + "yansi", ] [[package]] @@ -7951,6 +7989,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index f3f2d5b..ab5b633 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ eframe = { version = "0.34", default-features = false, features = ["default egui_extras = { version = "0.34", features = ["svg", "image"] } egui_dock = { version = "0.19", features = ["serde"] } rfd = "0.15" -iced = { version = "0.13", features = ["tokio", "image", "svg", "canvas", "wgpu", "advanced"] } +iced = { version = "0.13", features = ["tokio", "image", "svg", "canvas", "wgpu", "advanced", "lazy"] } # Utilities base64 = "0.22" diff --git a/hcie-composite/src/parallel.rs b/hcie-composite/src/parallel.rs index 8357a47..a4cc7d3 100644 --- a/hcie-composite/src/parallel.rs +++ b/hcie-composite/src/parallel.rs @@ -1,5 +1,4 @@ use hcie_blend::blend_pixels; -use hcie_fx::protocol_to_hcie_fx_effect; use hcie_protocol::Layer; use rayon::prelude::*; diff --git a/hcie-composite/src/tiled.rs b/hcie-composite/src/tiled.rs index 2758abd..daed378 100644 --- a/hcie-composite/src/tiled.rs +++ b/hcie-composite/src/tiled.rs @@ -1,6 +1,5 @@ use hcie_blend::blend_pixels; use hcie_fx::apply_layer_effects; -use hcie_fx::protocol_to_hcie_fx_effect; use hcie_protocol::Layer; use hcie_tile::{TiledLayer, TILE_SIZE}; use rayon::prelude::*; diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/egui_dock.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/egui_dock.rs index 92fee39..4f99d45 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/egui_dock.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/dock/egui_dock.rs @@ -630,12 +630,22 @@ impl<'a> TabViewer for HcieTabViewer<'a> { // Pin / float toggle for docked utility panels only. // LayerStyles, LayerDetails and Plugins are dialog-style panels: they // cannot float and do not show a pin toggle. - let supports_float = - !tab.is_document() && !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins); + let supports_float = !tab.is_document() + && !matches!( + tab, + HciePane::Tools + | HciePane::LayerStyles + | HciePane::LayerDetails + | HciePane::Plugins + ); if supports_float { // Pin icon is always visible on the right side of utility tabs so // users can float/re-dock a panel without first hovering the tab. - let pin_offset = if is_closeable && response.hovered() { 24.0 } else { 8.0 }; + let pin_offset = if is_closeable && response.hovered() { + 24.0 + } else { + 8.0 + }; let pin_center = egui::pos2(response.rect.right() - pin_offset, response.rect.center().y); let pin_rect = egui::Rect::from_center_size(pin_center, egui::vec2(14.0, 14.0)); @@ -750,7 +760,10 @@ impl<'a> TabViewer for HcieTabViewer<'a> { !tab.is_document() && !matches!( tab, - HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins + HciePane::Tools + | HciePane::LayerStyles + | HciePane::LayerDetails + | HciePane::Plugins ) } @@ -837,7 +850,13 @@ impl<'a> TabViewer for HcieTabViewer<'a> { // Float / close options intentionally disabled for LayerStyles, LayerDetails and Plugins // because these panels render as popups / dialogs, not docked utility cards. let is_floating_allowed = !is_doc - && !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins); + && !matches!( + tab, + HciePane::Tools + | HciePane::LayerStyles + | HciePane::LayerDetails + | HciePane::Plugins + ); if is_floating_allowed { let mut float = false; menu_item(ui, "Yeni Pencere", true, &mut float); @@ -863,7 +882,11 @@ impl<'a> TabViewer for HcieTabViewer<'a> { let close_enabled = self.is_closeable(tab); // Dialog-style panels (LayerStyles/LayerDetails/Plugins) are closed by the // dialog itself, not by the dock tab. - let close_in_context = close_enabled && !matches!(tab, HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins); + let close_in_context = close_enabled + && !matches!( + tab, + HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins + ); menu_item(ui, close_label, close_in_context, &mut close); if close && close_in_context { if let HciePane::Document(idx) = tab { 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 d7af697..eb3442a 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 @@ -11,9 +11,16 @@ 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}; +pub use crate::app::theme::{ + apply_theme, default_font_bytes, setup_custom_fonts, ThemeColors, ThemePreset, +}; +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; @@ -78,8 +85,6 @@ pub const TOOL_SLOTS: &[&[Tool]] = &[ ], ]; - - // ── HcieApp ────────────────────────────────────────────────────────────────── pub struct HcieApp { @@ -267,11 +272,8 @@ fn default_dock_state() -> egui_dock::DockState { // Default split fractions are chosen for a 1280px-wide window and are // immediately overridden by the layout solver using stored column widths. // They only determine the tree structure, not final pixel sizes. - let [remaining, left_col2] = tree.split_left( - egui_dock::NodeIndex::root(), - 0.17, - vec![HciePane::Brushes], - ); + let [remaining, left_col2] = + tree.split_left(egui_dock::NodeIndex::root(), 0.17, vec![HciePane::Brushes]); let [_left_brushes, _left_filters] = tree.split_below(left_col2, 0.5, vec![HciePane::Filters]); let [center, right_col2] = tree.split_right(remaining, 0.83, vec![HciePane::Layers]); @@ -677,7 +679,10 @@ impl HcieApp { 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_description(&format!( + "Added {} brush preset(s) to the Imported category.", + added + )) .set_level(rfd::MessageLevel::Info) .show(); } @@ -789,7 +794,7 @@ impl HcieApp { /// * `doc_pane`: The `dock::HciePane` document pane enum to push. pub fn push_document_to_center(&mut self, doc_pane: dock::HciePane) { let mut target_node = None; - + // 1. Prefer a leaf that already has document tabs for (path, leaf) in self.dock_state.iter_leaves() { if leaf.tabs.iter().any(|t| t.is_document()) { @@ -797,7 +802,7 @@ impl HcieApp { break; } } - + if let Some(node_path) = target_node { self.dock_state.set_focused_node_and_surface(node_path); self.dock_state.push_to_focused_leaf(doc_pane.clone()); @@ -806,24 +811,22 @@ impl HcieApp { // enforce_constraints will relocate it to the center if needed. self.dock_state.push_to_focused_leaf(doc_pane.clone()); } - + // Set the newly pushed tab as active to make it immediately visible if let Some(tab_path) = self.dock_state.find_tab(&doc_pane) { let _ = self.dock_state.set_active_tab(tab_path); } - + // Ensure dock layout settles and canvas texture uploads before the // user sees the new document. Without this, the canvas appears blank // on the first frame after opening a file. self.dock_settle_repaints = self.dock_settle_repaints.max(5); } - /// Push a newly opened/created document tab into the center document leaf. - fn push_document_to_center_placeholder() {} - - /// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab. + fn push_document_to_center_placeholder() {} + /// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab. /// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab. /// Returns `None` and logs a warning if indices are stale. @@ -1021,9 +1024,7 @@ impl HcieApp { } self.active_doc = new_idx; } else { - log::trace!( - "Removing document tab from egui_dock multi-document state" - ); + log::trace!("Removing document tab from egui_dock multi-document state"); let tabs_to_remove = self.dock_find_tabs( |t| matches!(t, dock::HciePane::Document(d) if *d == idx), ); @@ -1833,10 +1834,7 @@ impl HcieApp { .into_iter() .collect::>() } else { - crate::brush_import::import_abr_with_prefix( - &data, - &file_stem, - ) + crate::brush_import::import_abr_with_prefix(&data, &file_stem) }; if imported.is_empty() { rfd::MessageDialog::new() @@ -1850,12 +1848,21 @@ impl HcieApp { } else { let added = imported.len(); for preset in imported { - if !self.state.brush_presets.iter().any(|p| p.id == preset.id) { + 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); + log::info!( + "Added {} imported brush preset(s) from {:?}", + added, + path + ); rfd::MessageDialog::new() .set_title("Brush Import") .set_description(&format!( @@ -5118,7 +5125,9 @@ impl eframe::App for HcieApp { .state .brush_presets .iter() - .filter(|p| p.category == "Custom" || p.category == "Imported" || p.category == "Imported (ABR)") + .filter(|p| { + p.category == "Custom" || p.category == "Imported" || p.category == "Imported (ABR)" + }) .cloned() .collect(); if let Ok(json) = serde_json::to_string(&persisted_presets) { @@ -5139,4 +5148,3 @@ impl eframe::App for HcieApp { std::time::Duration::from_secs(120) } } - 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 index a23304b..3aa7270 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/theme.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/theme.rs @@ -218,26 +218,21 @@ pub fn apply_theme(ctx: &egui::Context, theme: ThemePreset) { }; // ── 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), - ); + 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); 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 74b7e22..432807b 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 @@ -18,7 +18,8 @@ fn matches_category(active_cat: &str, preset_cat: &str) -> bool { 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)"); + 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"], @@ -102,16 +103,17 @@ fn preset_thumb_hash(preset: &BrushPreset) -> u64 { hasher.finish() } -fn render_thumb_to_texture( - ctx: &egui::Context, - preset: &BrushPreset, -) -> egui::TextureHandle { +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) + 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) { @@ -132,7 +134,10 @@ fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usi } match style { BrushStyle::Bitmap => { - if preset.tip.bitmap_pixels.is_empty() || preset.tip.bitmap_w == 0 || preset.tip.bitmap_h == 0 { + 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; @@ -149,7 +154,9 @@ fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usi 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; + 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; @@ -160,7 +167,11 @@ fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usi 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 { + 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]; @@ -299,15 +310,23 @@ pub fn draw_brush_preview( hcie_engine_api::tools::BrushStyle::Texture => { 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 => { 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; @@ -359,14 +378,20 @@ pub fn draw_brush_preview( hcie_engine_api::tools::BrushStyle::Charcoal => { 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..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), + ); } } } @@ -379,7 +404,11 @@ 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; - painter.circle_stroke(egui::pos2(x, y), r, egui::Stroke::new(0.8, color.linear_multiply(0.12))); + painter.circle_stroke( + egui::pos2(x, y), + r, + egui::Stroke::new(0.8, color.linear_multiply(0.12)), + ); } } hcie_engine_api::tools::BrushStyle::Calligraphy => { @@ -394,14 +423,19 @@ pub fn draw_brush_preview( } hcie_engine_api::tools::BrushStyle::Leaf => { 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; } + if p < 0.15 { + continue; + } let shade = 0.35 + 0.55 * (i as f32 * 0.7).sin().abs(); let size = 1.8 + 2.0 * p; let lx = x + (i as f32 * 1.3).sin() * 1.5; 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), ); painter.rect_filled( leaf_rect.translate(egui::vec2(0.4, 0.6)), @@ -417,25 +451,36 @@ pub fn draw_brush_preview( } hcie_engine_api::tools::BrushStyle::Rock => { 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; } + 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 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)); + let rock_rect = + egui::Rect::from_center_size(egui::pos2(rx, ry), egui::vec2(rw, rh)); painter.rect_filled( - rock_rect, egui::CornerRadius::same(2), color.linear_multiply(shade), + rock_rect, + egui::CornerRadius::same(2), + color.linear_multiply(shade), ); } } hcie_engine_api::tools::BrushStyle::Meadow => { 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; } + 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(); @@ -444,7 +489,10 @@ pub fn draw_brush_preview( 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))); + painter.line_segment( + [mid, tip], + egui::Stroke::new(0.5, blade_color.linear_multiply(0.9)), + ); } } hcie_engine_api::tools::BrushStyle::Wood => { @@ -496,8 +544,16 @@ pub fn draw_brush_preview( 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 => { @@ -535,17 +591,25 @@ pub fn draw_brush_preview( hcie_engine_api::tools::BrushStyle::Crayon => { 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 => { 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 sz = 4.2 * p; painter.line_segment( @@ -564,7 +628,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), + ); } } } @@ -585,7 +653,10 @@ pub fn draw_brush_preview( let w = 5.2 * p; 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::pos2(x - w, y + w * 1.8), + egui::pos2(x + w, y - w * 1.8), + ], egui::Stroke::new(3.8, mc), ); } @@ -609,22 +680,38 @@ pub fn draw_brush_preview( } hcie_engine_api::tools::BrushStyle::Star => { 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 => { 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), + ); } } } @@ -1068,7 +1155,9 @@ 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", "Imported"]; + let categories = [ + "All", "Drawing", "Painting", "Effects", "Custom", "Imported", + ]; let _category_map = |cat: &str| -> Vec<&'static str> { match cat { "Drawing" => vec!["Basic", "Sketch"], @@ -1221,577 +1310,609 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) { state.primary_color[3], ); - crate::app::shell::scrollable_panel::scrollable_panel_with_scrollbar(ui, "brush_preset_list", true, |ui| { - match view_mode { - 2 => { - // CARD VIEW — Premium rounded cards with glow shadows and contrasting background - let presets = state.brush_presets.clone(); - let filtered: Vec = presets - .into_iter() - .filter(|p| { - let matches_cat = matches_category(&active_cat, &p.category); - let matches_search = search_term.is_empty() - || p.name.to_lowercase().contains(&search_term.to_lowercase()); - matches_cat && matches_search - }) - .collect(); + crate::app::shell::scrollable_panel::scrollable_panel_with_scrollbar( + ui, + "brush_preset_list", + true, + |ui| { + match view_mode { + 2 => { + // CARD VIEW — Premium rounded cards with glow shadows and contrasting background + let presets = state.brush_presets.clone(); + let filtered: Vec = presets + .into_iter() + .filter(|p| { + let matches_cat = matches_category(&active_cat, &p.category); + let matches_search = search_term.is_empty() + || p.name.to_lowercase().contains(&search_term.to_lowercase()); + matches_cat && matches_search + }) + .collect(); - if filtered.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(20.0); - ui.label( - egui::RichText::new("No matching presets.") - .size(11.0) - .color(colors.text_secondary), - ); - }); - } else { - for preset in &filtered { - let is_active = state.active_brush_preset_id == preset.id; - - // Allocate cell rectangle manually to enable precise layered styling - let (rect, response) = ui.allocate_exact_size( - egui::vec2(ui.available_width(), 46.0), - egui::Sense::click(), - ); - let cid = format!("panel.brushes.card.{}", preset.id); - let response = crate::app::shell::gui_layout::track(&cid, ui, &response); - let is_hovered = response.hovered(); - - // 8.0 rounded corners & dynamic glow shadows - if is_active { - crate::app::widgets::draw_hover_shadow(ui, rect, colors.accent); - } else if is_hovered { - crate::app::widgets::draw_hover_shadow( - ui, - rect, - colors.accent.gamma_multiply(0.35), - ); - } - - let fill_color = if is_active { - colors.bg_hover - } else if is_hovered { - colors.bg_panel.linear_multiply(1.15) - } else { - colors.bg_panel - }; - - let border_stroke = egui::Stroke::new( - if is_active { 1.2 } else { 1.0 }, - if is_active { - colors.accent - } else { - colors.border_high - }, - ); - - let painter = ui.painter(); - painter.rect_filled(rect, egui::CornerRadius::same(8), fill_color); - painter.rect_stroke( - rect, - egui::CornerRadius::same(8), - border_stroke, - egui::StrokeKind::Outside, - ); - - // Brush Swatch Preview - let preview_rect = egui::Rect::from_min_size( - rect.left_top() + egui::vec2(8.0, 7.0), - egui::vec2(70.0, 32.0), - ); - let bg_fill = if colors.is_light { - egui::Color32::from_gray(245) - } else { - egui::Color32::from_gray(28) - }; - painter.rect_filled(preview_rect, egui::CornerRadius::same(4), bg_fill); - painter.rect_stroke( - preview_rect, - egui::CornerRadius::same(4), - egui::Stroke::new(0.5, colors.border_low), - egui::StrokeKind::Outside, - ); - 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); - painter.text( - name_pos, - egui::Align2::LEFT_TOP, - &preset.name, - egui::FontId::proportional(11.5), - if is_active { - colors.accent - } else { - colors.text_primary - }, - ); - - if preset.category == "Custom" { - let badge_txt = if colors.is_light { - egui::Color32::from_rgb(0, 100, 200) - } else { - egui::Color32::from_rgb(100, 200, 255) - }; - let name_width = painter - .layout_no_wrap( - preset.name.clone(), - egui::FontId::proportional(11.5), - colors.text_primary, - ) - .rect - .width(); - painter.text( - name_pos + egui::vec2(name_width + 6.0, 1.0), - egui::Align2::LEFT_TOP, - "User", - egui::FontId::proportional(9.0), - badge_txt, - ); - } - - let desc_pos = rect.left_top() + egui::vec2(86.0, 24.0); - painter.text( - desc_pos, - egui::Align2::LEFT_TOP, - format!( - "Size: {:.0}px | Opacity: {:.0}%", - preset.tip.size, - preset.tip.opacity * 100.0 - ), - egui::FontId::proportional(9.5), - colors.text_secondary, - ); - - if response.clicked() { - apply_brush_preset(state, preset); - } - - if response.secondary_clicked() && preset.category == "Custom" { - ui.memory_mut(|m| m.open_popup(response.id.with("ctx_menu"))); - } - let preset_id_for_ctx = preset.id.clone(); - let preset_name_for_ctx = preset.name.clone(); - let rename_id = egui::Id::new(("brush_rename", &preset_id_for_ctx)); - response.context_menu(|ui| { - if preset.category != "Custom" { - return; - } - let mut buf = ui.data(|d| { - d.get_temp::(rename_id) - .unwrap_or_else(|| preset_name_for_ctx.clone()) - }); + if filtered.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(20.0); ui.label( - egui::RichText::new("Rename:") - .size(10.0) + egui::RichText::new("No matching presets.") + .size(11.0) .color(colors.text_secondary), ); - let resp = - ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0)); - if resp.changed() { - ui.data_mut(|d| d.insert_temp(rename_id, buf.clone())); - } - ui.horizontal(|ui| { - if ui.small_button("✓ Rename").clicked() && !buf.trim().is_empty() - { - if let Some(p) = state - .brush_presets - .iter_mut() - .find(|p| p.id == preset_id_for_ctx) - { - p.name = buf; - } - } - if ui.small_button("\u{2715} Delete").clicked() { - state.brush_presets.retain(|p| p.id != preset_id_for_ctx); - if state.active_brush_preset_id == preset_id_for_ctx { - state.active_brush_preset_id.clear(); - } - } - }); }); + } else { + for preset in &filtered { + let is_active = state.active_brush_preset_id == preset.id; - ui.add_space(5.0); - } - } - } - 1 => { - // LIST VIEW — compact rows - let presets = state.brush_presets.clone(); - let filtered: Vec = presets - .into_iter() - .filter(|p| { - let matches_cat = matches_category(&active_cat, &p.category); - let matches_search = search_term.is_empty() - || p.name.to_lowercase().contains(&search_term.to_lowercase()); - matches_cat && matches_search - }) - .collect(); - - if filtered.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(20.0); - ui.label( - egui::RichText::new("No matching presets.") - .size(11.0) - .color(colors.text_secondary), - ); - }); - } else { - for preset in &filtered { - let is_active = state.active_brush_preset_id == preset.id; - - let (rect, resp) = ui.allocate_exact_size( - egui::vec2(ui.available_width(), 32.0), - egui::Sense::click(), - ); - let lid = format!("panel.brushes.list.{}", preset.id); - let resp = crate::app::shell::gui_layout::track(&lid, ui, &resp); - - let painter = ui.painter(); - if is_active { - painter.rect_filled(rect, egui::CornerRadius::same(3), colors.bg_hover); - painter.rect_filled( - egui::Rect::from_min_size( - rect.left_top(), - egui::vec2(3.0, rect.height()), - ), - egui::CornerRadius::same(1), - colors.accent, - ); - } - if resp.hovered() && !is_active { - painter.rect_filled( - rect, - egui::CornerRadius::same(3), - colors.bg_hover.linear_multiply(0.4), - ); - } - - // Small preview - let preview_rect = egui::Rect::from_min_size( - rect.left_top() + egui::vec2(6.0, 5.0), - egui::vec2(40.0, 22.0), - ); - let bg_fill = if colors.is_light { - egui::Color32::from_gray(245) - } else { - egui::Color32::from_gray(28) - }; - painter.rect_filled(preview_rect, egui::CornerRadius::same(2), bg_fill); - 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 = - egui::pos2(preview_rect.right() + 8.0, rect.center().y - 5.0); - painter.text( - name_pos, - egui::Align2::LEFT_BOTTOM, - &preset.name, - egui::FontId::proportional(11.0), - if is_active { - colors.accent - } else { - colors.text_primary - }, - ); - - // Size/Opacity info - let info_pos = - egui::pos2(preview_rect.right() + 8.0, rect.center().y + 5.0); - painter.text( - info_pos, - egui::Align2::LEFT_TOP, - format!( - "{:.0}px {:.0}%", - preset.tip.size, - preset.tip.opacity * 100.0 - ), - egui::FontId::proportional(9.0), - colors.text_secondary, - ); - - ui.add_space(4.0); - - if resp.clicked() { - apply_brush_preset(state, preset); - } - - if resp.secondary_clicked() && preset.category == "Custom" { - ui.memory_mut(|m| m.open_popup(resp.id.with("ctx_menu"))); - } - let preset_id_for_ctx = preset.id.clone(); - let preset_name_for_ctx = preset.name.clone(); - let rename_id = egui::Id::new(("brush_rename", &preset_id_for_ctx)); - resp.context_menu(|ui| { - if preset.category != "Custom" { - return; - } - let mut buf = ui.data(|d| { - d.get_temp::(rename_id) - .unwrap_or_else(|| preset_name_for_ctx.clone()) - }); - ui.label( - egui::RichText::new("Rename:") - .size(10.0) - .color(colors.text_secondary), - ); - let r = - ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0)); - if r.changed() { - ui.data_mut(|d| d.insert_temp(rename_id, buf.clone())); - } - ui.horizontal(|ui| { - if ui.small_button("✓ Rename").clicked() && !buf.trim().is_empty() - { - if let Some(p) = state - .brush_presets - .iter_mut() - .find(|p| p.id == preset_id_for_ctx) - { - p.name = buf; - } - } - if ui.small_button("\u{2715} Delete").clicked() { - state.brush_presets.retain(|p| p.id != preset_id_for_ctx); - if state.active_brush_preset_id == preset_id_for_ctx { - state.active_brush_preset_id.clear(); - } - } - }); - }); - } - } - } - 0 => { - // STYLES VIEW — 24 brush style grid buttons with 8.0 rounded cells, outlines, and active shadow glows - let all_styles = [ - (hcie_engine_api::tools::BrushStyle::Default, "Default"), - (hcie_engine_api::tools::BrushStyle::Oil, "Oil"), - (hcie_engine_api::tools::BrushStyle::Charcoal, "Charcoal"), - (hcie_engine_api::tools::BrushStyle::Leaf, "Leaf"), - (hcie_engine_api::tools::BrushStyle::Rock, "Rock"), - (hcie_engine_api::tools::BrushStyle::Meadow, "Meadow"), - (hcie_engine_api::tools::BrushStyle::Wood, "Wood"), - (hcie_engine_api::tools::BrushStyle::Watercolor, "Watercolor"), - ( - hcie_engine_api::tools::BrushStyle::Calligraphy, - "Calligraphy", - ), - (hcie_engine_api::tools::BrushStyle::Marker, "Marker"), - (hcie_engine_api::tools::BrushStyle::Sketch, "Sketch"), - (hcie_engine_api::tools::BrushStyle::Hatch, "Hatch"), - (hcie_engine_api::tools::BrushStyle::Star, "Stipple Dots"), - (hcie_engine_api::tools::BrushStyle::Glow, "Glow"), - (hcie_engine_api::tools::BrushStyle::Airbrush, "Airbrush"), - (hcie_engine_api::tools::BrushStyle::Pencil, "Pencil"), - (hcie_engine_api::tools::BrushStyle::Crayon, "Crayon"), - (hcie_engine_api::tools::BrushStyle::WetPaint, "Wet Paint"), - (hcie_engine_api::tools::BrushStyle::InkPen, "Ink Pen"), - (hcie_engine_api::tools::BrushStyle::Clouds, "Clouds"), - (hcie_engine_api::tools::BrushStyle::Dirt, "Dirt"), - (hcie_engine_api::tools::BrushStyle::Tree, "Tree"), - (hcie_engine_api::tools::BrushStyle::Bristle, "Bristle"), - (hcie_engine_api::tools::BrushStyle::Mixer, "Mixer"), - (hcie_engine_api::tools::BrushStyle::Blender, "Blender"), - (hcie_engine_api::tools::BrushStyle::Bitmap, "Imported"), - ]; - - let current_style = state.tool_configs.brush.style; - let spacing = 4.0; - let cell_w = 56.0_f32; // compact square cell - let cell_h = 44.0_f32; - - // Filter styles by active category - let filtered_styles: Vec<_> = all_styles - .iter() - .filter(|&&(style, _)| matches_style_category(&active_cat, style)) - .cloned() - .collect(); - - // Explicit row layout: compute how many cells fit in the current - // panel width, then lay each row out as a separate horizontal so - // reflow always follows the actual width on every frame. - // Use max_rect().width() instead of available_width() because the - // latter can report the ScrollArea content width from a previous - // frame, which locks the grid to a fixed column count. - let panel_w = ui.max_rect().width(); - let total_cell_w = cell_w + spacing; - let cols = ((panel_w + spacing) / total_cell_w).floor().max(1.0) as usize; - - let mut idx = 0; - while idx < filtered_styles.len() { - let end = (idx + cols).min(filtered_styles.len()); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = spacing; - ui.spacing_mut().item_spacing.y = 0.0; - for &(style, name) in &filtered_styles[idx..end] { - let is_active = current_style == style; - - let (cell_rect, cell_resp) = ui.allocate_exact_size( - egui::vec2(cell_w, cell_h), + // Allocate cell rectangle manually to enable precise layered styling + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 46.0), egui::Sense::click(), ); - let gid = format!("panel.brushes.grid.{}", name); - let cell_resp = - crate::app::shell::gui_layout::track(&gid, ui, &cell_resp); - let is_hovered = cell_resp.hovered(); + let cid = format!("panel.brushes.card.{}", preset.id); + let response = + crate::app::shell::gui_layout::track(&cid, ui, &response); + let is_hovered = response.hovered(); + // 8.0 rounded corners & dynamic glow shadows if is_active { - crate::app::widgets::draw_hover_shadow( - ui, - cell_rect, - colors.accent, - ); + crate::app::widgets::draw_hover_shadow(ui, rect, colors.accent); } else if is_hovered { crate::app::widgets::draw_hover_shadow( ui, - cell_rect, + rect, colors.accent.gamma_multiply(0.35), ); } - let painter = ui.painter(); - let bg = if is_active { + let fill_color = if is_active { colors.bg_hover } else if is_hovered { colors.bg_panel.linear_multiply(1.15) } else { colors.bg_panel }; - painter.rect_filled(cell_rect, egui::CornerRadius::same(6), bg); + + let border_stroke = egui::Stroke::new( + if is_active { 1.2 } else { 1.0 }, + if is_active { + colors.accent + } else { + colors.border_high + }, + ); + + let painter = ui.painter(); + painter.rect_filled(rect, egui::CornerRadius::same(8), fill_color); painter.rect_stroke( - cell_rect, - egui::CornerRadius::same(6), - egui::Stroke::new( - if is_active { 1.2 } else { 1.0 }, - if is_active { - colors.accent - } else { - colors.border_high - }, - ), + rect, + egui::CornerRadius::same(8), + border_stroke, egui::StrokeKind::Outside, ); + // Brush Swatch Preview let preview_rect = egui::Rect::from_min_size( - cell_rect.left_top() + egui::vec2(3.0, 3.0), - egui::vec2(cell_w - 6.0, 22.0), + rect.left_top() + egui::vec2(8.0, 7.0), + egui::vec2(70.0, 32.0), ); let bg_fill = if colors.is_light { egui::Color32::from_gray(245) } else { egui::Color32::from_gray(28) }; - painter.rect_filled(preview_rect, egui::CornerRadius::same(3), bg_fill); + painter.rect_filled(preview_rect, egui::CornerRadius::same(4), bg_fill); painter.rect_stroke( preview_rect, - egui::CornerRadius::same(3), + egui::CornerRadius::same(4), egui::Stroke::new(0.5, colors.border_low), egui::StrokeKind::Outside, ); - draw_brush_preview(painter, preview_rect, style, primary_srgba, None); + 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, + ); + } - let name_pos = - egui::pos2(cell_rect.center().x, preview_rect.bottom() + 1.0); + // Swatch Label Details + let name_pos = rect.left_top() + egui::vec2(86.0, 8.0); painter.text( name_pos, - egui::Align2::CENTER_TOP, - name.to_string(), - egui::FontId::proportional(8.0), + egui::Align2::LEFT_TOP, + &preset.name, + egui::FontId::proportional(11.5), if is_active { colors.accent } else { - colors.text_secondary + colors.text_primary }, ); - if cell_resp.clicked() { - state.tool_configs.brush.style = style; - state.active_tool = hcie_engine_api::Tool::Brush; + if preset.category == "Custom" { + let badge_txt = if colors.is_light { + egui::Color32::from_rgb(0, 100, 200) + } else { + egui::Color32::from_rgb(100, 200, 255) + }; + let name_width = painter + .layout_no_wrap( + preset.name.clone(), + egui::FontId::proportional(11.5), + colors.text_primary, + ) + .rect + .width(); + painter.text( + name_pos + egui::vec2(name_width + 6.0, 1.0), + egui::Align2::LEFT_TOP, + "User", + egui::FontId::proportional(9.0), + badge_txt, + ); } + + let desc_pos = rect.left_top() + egui::vec2(86.0, 24.0); + painter.text( + desc_pos, + egui::Align2::LEFT_TOP, + format!( + "Size: {:.0}px | Opacity: {:.0}%", + preset.tip.size, + preset.tip.opacity * 100.0 + ), + egui::FontId::proportional(9.5), + colors.text_secondary, + ); + + if response.clicked() { + apply_brush_preset(state, preset); + } + + if response.secondary_clicked() && preset.category == "Custom" { + ui.memory_mut(|m| m.open_popup(response.id.with("ctx_menu"))); + } + let preset_id_for_ctx = preset.id.clone(); + let preset_name_for_ctx = preset.name.clone(); + let rename_id = egui::Id::new(("brush_rename", &preset_id_for_ctx)); + response.context_menu(|ui| { + if preset.category != "Custom" { + return; + } + let mut buf = ui.data(|d| { + d.get_temp::(rename_id) + .unwrap_or_else(|| preset_name_for_ctx.clone()) + }); + ui.label( + egui::RichText::new("Rename:") + .size(10.0) + .color(colors.text_secondary), + ); + let resp = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(140.0)); + if resp.changed() { + ui.data_mut(|d| d.insert_temp(rename_id, buf.clone())); + } + ui.horizontal(|ui| { + if ui.small_button("✓ Rename").clicked() + && !buf.trim().is_empty() + { + if let Some(p) = state + .brush_presets + .iter_mut() + .find(|p| p.id == preset_id_for_ctx) + { + p.name = buf; + } + } + if ui.small_button("\u{2715} Delete").clicked() { + state.brush_presets.retain(|p| p.id != preset_id_for_ctx); + if state.active_brush_preset_id == preset_id_for_ctx { + state.active_brush_preset_id.clear(); + } + } + }); + }); + + ui.add_space(5.0); } - }); - idx = end; + } } - } - _ => {} - } + 1 => { + // LIST VIEW — compact rows + let presets = state.brush_presets.clone(); + let filtered: Vec = presets + .into_iter() + .filter(|p| { + let matches_cat = matches_category(&active_cat, &p.category); + let matches_search = search_term.is_empty() + || p.name.to_lowercase().contains(&search_term.to_lowercase()); + matches_cat && matches_search + }) + .collect(); - ui.add_space(8.0); - - // 5. Save Custom Preset Box - ui.label( - egui::RichText::new("Save Brush Configuration") - .size(11.0) - .strong() - .color(colors.text_primary), - ); - ui.add_space(2.0); - ui.horizontal(|ui| { - let resp = ui.add( - egui::TextEdit::singleline(&mut save_name) - .hint_text("Preset name...") - .desired_width(ui.available_width() - 72.0), - ); - if resp.changed() { - ui.data_mut(|d| d.insert_temp(save_input_id, save_name.clone())); - } - - let can_save = !save_name.trim().is_empty(); - let save_btn = ui.add_enabled( - can_save, - egui::Button::new(egui::RichText::new("➕ Save").size(11.0).strong().color( - if can_save { - egui::Color32::WHITE + if filtered.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.label( + egui::RichText::new("No matching presets.") + .size(11.0) + .color(colors.text_secondary), + ); + }); } else { - colors.text_secondary - }, - )) - .fill(colors.accent), - ); - if save_btn.clicked() { - save_brush_preset(state, save_name.clone()); - app.event_bus.push(AppEvent::ShowStatusMessage(format!( - "Brush preset '{}' saved.", - save_name - ))); - // Reset input name - save_name = "My Custom Tip".to_string(); - ui.data_mut(|d| d.insert_temp(save_input_id, save_name.clone())); - // Switch to Custom category so user can see the new preset - active_cat = "Custom".to_string(); - ui.data_mut(|d| d.insert_temp(category_id, active_cat.clone())); + for preset in &filtered { + let is_active = state.active_brush_preset_id == preset.id; + + let (rect, resp) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 32.0), + egui::Sense::click(), + ); + let lid = format!("panel.brushes.list.{}", preset.id); + let resp = crate::app::shell::gui_layout::track(&lid, ui, &resp); + + let painter = ui.painter(); + if is_active { + painter.rect_filled( + rect, + egui::CornerRadius::same(3), + colors.bg_hover, + ); + painter.rect_filled( + egui::Rect::from_min_size( + rect.left_top(), + egui::vec2(3.0, rect.height()), + ), + egui::CornerRadius::same(1), + colors.accent, + ); + } + if resp.hovered() && !is_active { + painter.rect_filled( + rect, + egui::CornerRadius::same(3), + colors.bg_hover.linear_multiply(0.4), + ); + } + + // Small preview + let preview_rect = egui::Rect::from_min_size( + rect.left_top() + egui::vec2(6.0, 5.0), + egui::vec2(40.0, 22.0), + ); + let bg_fill = if colors.is_light { + egui::Color32::from_gray(245) + } else { + egui::Color32::from_gray(28) + }; + painter.rect_filled(preview_rect, egui::CornerRadius::same(2), bg_fill); + 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 = + egui::pos2(preview_rect.right() + 8.0, rect.center().y - 5.0); + painter.text( + name_pos, + egui::Align2::LEFT_BOTTOM, + &preset.name, + egui::FontId::proportional(11.0), + if is_active { + colors.accent + } else { + colors.text_primary + }, + ); + + // Size/Opacity info + let info_pos = + egui::pos2(preview_rect.right() + 8.0, rect.center().y + 5.0); + painter.text( + info_pos, + egui::Align2::LEFT_TOP, + format!( + "{:.0}px {:.0}%", + preset.tip.size, + preset.tip.opacity * 100.0 + ), + egui::FontId::proportional(9.0), + colors.text_secondary, + ); + + ui.add_space(4.0); + + if resp.clicked() { + apply_brush_preset(state, preset); + } + + if resp.secondary_clicked() && preset.category == "Custom" { + ui.memory_mut(|m| m.open_popup(resp.id.with("ctx_menu"))); + } + let preset_id_for_ctx = preset.id.clone(); + let preset_name_for_ctx = preset.name.clone(); + let rename_id = egui::Id::new(("brush_rename", &preset_id_for_ctx)); + resp.context_menu(|ui| { + if preset.category != "Custom" { + return; + } + let mut buf = ui.data(|d| { + d.get_temp::(rename_id) + .unwrap_or_else(|| preset_name_for_ctx.clone()) + }); + ui.label( + egui::RichText::new("Rename:") + .size(10.0) + .color(colors.text_secondary), + ); + let r = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(120.0)); + if r.changed() { + ui.data_mut(|d| d.insert_temp(rename_id, buf.clone())); + } + ui.horizontal(|ui| { + if ui.small_button("✓ Rename").clicked() + && !buf.trim().is_empty() + { + if let Some(p) = state + .brush_presets + .iter_mut() + .find(|p| p.id == preset_id_for_ctx) + { + p.name = buf; + } + } + if ui.small_button("\u{2715} Delete").clicked() { + state.brush_presets.retain(|p| p.id != preset_id_for_ctx); + if state.active_brush_preset_id == preset_id_for_ctx { + state.active_brush_preset_id.clear(); + } + } + }); + }); + } + } + } + 0 => { + // STYLES VIEW — 24 brush style grid buttons with 8.0 rounded cells, outlines, and active shadow glows + let all_styles = [ + (hcie_engine_api::tools::BrushStyle::Default, "Default"), + (hcie_engine_api::tools::BrushStyle::Oil, "Oil"), + (hcie_engine_api::tools::BrushStyle::Charcoal, "Charcoal"), + (hcie_engine_api::tools::BrushStyle::Leaf, "Leaf"), + (hcie_engine_api::tools::BrushStyle::Rock, "Rock"), + (hcie_engine_api::tools::BrushStyle::Meadow, "Meadow"), + (hcie_engine_api::tools::BrushStyle::Wood, "Wood"), + (hcie_engine_api::tools::BrushStyle::Watercolor, "Watercolor"), + ( + hcie_engine_api::tools::BrushStyle::Calligraphy, + "Calligraphy", + ), + (hcie_engine_api::tools::BrushStyle::Marker, "Marker"), + (hcie_engine_api::tools::BrushStyle::Sketch, "Sketch"), + (hcie_engine_api::tools::BrushStyle::Hatch, "Hatch"), + (hcie_engine_api::tools::BrushStyle::Star, "Stipple Dots"), + (hcie_engine_api::tools::BrushStyle::Glow, "Glow"), + (hcie_engine_api::tools::BrushStyle::Airbrush, "Airbrush"), + (hcie_engine_api::tools::BrushStyle::Pencil, "Pencil"), + (hcie_engine_api::tools::BrushStyle::Crayon, "Crayon"), + (hcie_engine_api::tools::BrushStyle::WetPaint, "Wet Paint"), + (hcie_engine_api::tools::BrushStyle::InkPen, "Ink Pen"), + (hcie_engine_api::tools::BrushStyle::Clouds, "Clouds"), + (hcie_engine_api::tools::BrushStyle::Dirt, "Dirt"), + (hcie_engine_api::tools::BrushStyle::Tree, "Tree"), + (hcie_engine_api::tools::BrushStyle::Bristle, "Bristle"), + (hcie_engine_api::tools::BrushStyle::Mixer, "Mixer"), + (hcie_engine_api::tools::BrushStyle::Blender, "Blender"), + (hcie_engine_api::tools::BrushStyle::Bitmap, "Imported"), + ]; + + let current_style = state.tool_configs.brush.style; + let spacing = 4.0; + let cell_w = 56.0_f32; // compact square cell + let cell_h = 44.0_f32; + + // Filter styles by active category + let filtered_styles: Vec<_> = all_styles + .iter() + .filter(|&&(style, _)| matches_style_category(&active_cat, style)) + .cloned() + .collect(); + + // Explicit row layout: compute how many cells fit in the current + // panel width, then lay each row out as a separate horizontal so + // reflow always follows the actual width on every frame. + // Use max_rect().width() instead of available_width() because the + // latter can report the ScrollArea content width from a previous + // frame, which locks the grid to a fixed column count. + let panel_w = ui.max_rect().width(); + let total_cell_w = cell_w + spacing; + let cols = ((panel_w + spacing) / total_cell_w).floor().max(1.0) as usize; + + let mut idx = 0; + while idx < filtered_styles.len() { + let end = (idx + cols).min(filtered_styles.len()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = spacing; + ui.spacing_mut().item_spacing.y = 0.0; + for &(style, name) in &filtered_styles[idx..end] { + let is_active = current_style == style; + + let (cell_rect, cell_resp) = ui.allocate_exact_size( + egui::vec2(cell_w, cell_h), + egui::Sense::click(), + ); + let gid = format!("panel.brushes.grid.{}", name); + let cell_resp = + crate::app::shell::gui_layout::track(&gid, ui, &cell_resp); + let is_hovered = cell_resp.hovered(); + + if is_active { + crate::app::widgets::draw_hover_shadow( + ui, + cell_rect, + colors.accent, + ); + } else if is_hovered { + crate::app::widgets::draw_hover_shadow( + ui, + cell_rect, + colors.accent.gamma_multiply(0.35), + ); + } + + let painter = ui.painter(); + let bg = if is_active { + colors.bg_hover + } else if is_hovered { + colors.bg_panel.linear_multiply(1.15) + } else { + colors.bg_panel + }; + painter.rect_filled(cell_rect, egui::CornerRadius::same(6), bg); + painter.rect_stroke( + cell_rect, + egui::CornerRadius::same(6), + egui::Stroke::new( + if is_active { 1.2 } else { 1.0 }, + if is_active { + colors.accent + } else { + colors.border_high + }, + ), + egui::StrokeKind::Outside, + ); + + let preview_rect = egui::Rect::from_min_size( + cell_rect.left_top() + egui::vec2(3.0, 3.0), + egui::vec2(cell_w - 6.0, 22.0), + ); + let bg_fill = if colors.is_light { + egui::Color32::from_gray(245) + } else { + egui::Color32::from_gray(28) + }; + painter.rect_filled( + preview_rect, + egui::CornerRadius::same(3), + bg_fill, + ); + painter.rect_stroke( + preview_rect, + egui::CornerRadius::same(3), + egui::Stroke::new(0.5, colors.border_low), + egui::StrokeKind::Outside, + ); + draw_brush_preview( + painter, + preview_rect, + style, + primary_srgba, + None, + ); + + let name_pos = + egui::pos2(cell_rect.center().x, preview_rect.bottom() + 1.0); + painter.text( + name_pos, + egui::Align2::CENTER_TOP, + name.to_string(), + egui::FontId::proportional(8.0), + if is_active { + colors.accent + } else { + colors.text_secondary + }, + ); + + if cell_resp.clicked() { + state.tool_configs.brush.style = style; + state.active_tool = hcie_engine_api::Tool::Brush; + } + } + }); + idx = end; + } + } + _ => {} } - }); - }); + + ui.add_space(8.0); + + // 5. Save Custom Preset Box + ui.label( + egui::RichText::new("Save Brush Configuration") + .size(11.0) + .strong() + .color(colors.text_primary), + ); + ui.add_space(2.0); + ui.horizontal(|ui| { + let resp = ui.add( + egui::TextEdit::singleline(&mut save_name) + .hint_text("Preset name...") + .desired_width(ui.available_width() - 72.0), + ); + if resp.changed() { + ui.data_mut(|d| d.insert_temp(save_input_id, save_name.clone())); + } + + let can_save = !save_name.trim().is_empty(); + let save_btn = ui.add_enabled( + can_save, + egui::Button::new(egui::RichText::new("➕ Save").size(11.0).strong().color( + if can_save { + egui::Color32::WHITE + } else { + colors.text_secondary + }, + )) + .fill(colors.accent), + ); + if save_btn.clicked() { + save_brush_preset(state, save_name.clone()); + app.event_bus.push(AppEvent::ShowStatusMessage(format!( + "Brush preset '{}' saved.", + save_name + ))); + // Reset input name + save_name = "My Custom Tip".to_string(); + ui.data_mut(|d| d.insert_temp(save_input_id, save_name.clone())); + // Switch to Custom category so user can see the new preset + active_cat = "Custom".to_string(); + ui.data_mut(|d| d.insert_temp(category_id, active_cat.clone())); + } + }); + }, + ); // 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/tools/layer_styles_panel.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/layer_styles_panel.rs index fdd6512..b558134 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/layer_styles_panel.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/layer_styles_panel.rs @@ -102,6 +102,7 @@ pub fn show_layer_styles_panel(doc: &mut AppDocument, state: &mut ToolState, ui: size: 5.0, angle: 120.0, altitude: 30.0, + contour: "Linear".to_string(), highlight_opacity: 0.75, shadow_opacity: 0.75, direction: "Up".to_string(), @@ -352,6 +353,49 @@ fn technique_combobox(ui: &mut egui::Ui, current: &mut String) -> bool { changed } +/// **Purpose:** +/// Displays a dropdown combo box to select the bevel contour profile. +/// +/// **Logic & Workflow:** +/// Lists common contour presets (Linear, Cone, Gaussian, etc.). +/// +/// **Arguments:** +/// - `ui`: Mutable egui UI layout context. +/// - `current`: Mutable reference to the contour string. +/// +/// **Returns:** +/// - `bool`: True if selection changed. +fn contour_combobox(ui: &mut egui::Ui, current: &mut String) -> bool { + let mut changed = false; + let contours = [ + "Linear", + "Cone", + "Cone-Inverted", + "Gaussian", + "Half Round", + "Round", + "Ring", + "Ring-Double", + "Sawtooth", + "Square", + "Valley", + "Shallow-Slope", + "Wave", + "Cove", + "Washboard", + ]; + egui::ComboBox::from_label("Contour") + .selected_text(current.as_str()) + .show_ui(ui, |ui| { + for c in contours { + if ui.selectable_value(current, c.to_string(), c).changed() { + changed = true; + } + } + }); + changed +} + /// **Purpose:** /// Displays a dropdown combo box to select the stroke position. /// @@ -476,7 +520,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -527,7 +571,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -576,7 +620,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -619,7 +663,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -662,6 +706,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The highlight_color, shadow_blend_mode, shadow_color, + contour, } => { let title = "Bevel & Emboss"; let header_color = Color32::from_rgb(100, 180, 220); @@ -671,7 +716,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -718,6 +763,18 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The }); ui.end_row(); + ui.label( + RichText::new("Contour") + .size(10.0) + .color(colors.text_secondary), + ); + ui.horizontal(|ui| { + if contour_combobox(ui, contour) { + changed = true; + } + }); + ui.end_row(); + ui.label( RichText::new("Depth") .size(10.0) @@ -898,7 +955,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -942,7 +999,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -979,7 +1036,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -1021,7 +1078,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); @@ -1059,7 +1116,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The false, ) .show_header(ui, |ui| { - if ui.checkbox(enabled, "").changed() { + if ui.checkbox(enabled, String::new()).changed() { changed = true; } ui.label(RichText::new(title).strong().color(header_color)); 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 index 1a301b2..e9d29a8 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/utils.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/utils.rs @@ -149,7 +149,11 @@ pub fn suggest_alternative_path(original: &std::path::Path) -> std::path::PathBu /// * `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 { +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); 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 b80c274..e75fffc 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 @@ -252,7 +252,8 @@ fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec Vec { 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 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; @@ -555,7 +557,12 @@ pub fn import_kpp(data: &[u8], name_hint: &str) -> Option { } } Some(BrushPreset { - id: format!("kpp_imported_{}_{}x{}", name_hint.replace(' ', "_"), final_w, final_h), + id: format!( + "kpp_imported_{}_{}x{}", + name_hint.replace(' ', "_"), + final_w, + final_h + ), name: format!("{} (KPP)", name_hint), category: "Imported".to_string(), tip: BrushTip { @@ -623,7 +630,10 @@ mod tests { } }; let presets = import_abr(&data); - let bitmap: Vec<_> = presets.iter().filter(|p| p.style == BrushStyle::Bitmap).collect(); + 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 bf3e9c3..400c818 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 @@ -1098,7 +1098,10 @@ impl<'a> egui::Widget for CanvasWidget<'a> { let t_stroke_end = std::time::Instant::now(); let layer_id = self.doc.engine.active_layer_id(); self.doc.engine.end_stroke(layer_id); - log::warn!("[PERF] self.doc.engine.end_stroke took {}ms", t_stroke_end.elapsed().as_millis()); + log::warn!( + "[PERF] self.doc.engine.end_stroke took {}ms", + t_stroke_end.elapsed().as_millis() + ); self.doc.last_stroke_end_time = Some(std::time::Instant::now()); let desc = match self.state.active_tool { @@ -1474,40 +1477,108 @@ impl<'a> egui::Widget for CanvasWidget<'a> { } Tool::Brush | _ => { tip.style = match self.state.tool_configs.brush.style { - hcie_engine_api::tools::BrushStyle::Default => hcie_engine_api::BrushStyle::Round, - hcie_engine_api::tools::BrushStyle::Round => hcie_engine_api::BrushStyle::Round, - hcie_engine_api::tools::BrushStyle::Square => hcie_engine_api::BrushStyle::Square, - hcie_engine_api::tools::BrushStyle::HardRound => hcie_engine_api::BrushStyle::HardRound, - hcie_engine_api::tools::BrushStyle::SoftRound => hcie_engine_api::BrushStyle::SoftRound, - hcie_engine_api::tools::BrushStyle::Noise => hcie_engine_api::BrushStyle::Noise, - hcie_engine_api::tools::BrushStyle::Texture => hcie_engine_api::BrushStyle::Texture, - hcie_engine_api::tools::BrushStyle::Spray => hcie_engine_api::BrushStyle::Spray, - hcie_engine_api::tools::BrushStyle::Pen => hcie_engine_api::BrushStyle::Pen, - hcie_engine_api::tools::BrushStyle::Oil => hcie_engine_api::BrushStyle::Oil, - hcie_engine_api::tools::BrushStyle::Charcoal => hcie_engine_api::BrushStyle::Charcoal, - hcie_engine_api::tools::BrushStyle::Leaf => hcie_engine_api::BrushStyle::Leaf, - hcie_engine_api::tools::BrushStyle::Rock => hcie_engine_api::BrushStyle::Rock, - hcie_engine_api::tools::BrushStyle::Meadow => hcie_engine_api::BrushStyle::Meadow, - hcie_engine_api::tools::BrushStyle::Wood => hcie_engine_api::BrushStyle::Wood, - hcie_engine_api::tools::BrushStyle::Watercolor => hcie_engine_api::BrushStyle::Watercolor, - hcie_engine_api::tools::BrushStyle::Calligraphy => hcie_engine_api::BrushStyle::Calligraphy, - hcie_engine_api::tools::BrushStyle::Marker => hcie_engine_api::BrushStyle::Marker, - hcie_engine_api::tools::BrushStyle::Sketch => hcie_engine_api::BrushStyle::Sketch, - hcie_engine_api::tools::BrushStyle::Hatch => hcie_engine_api::BrushStyle::Hatch, - hcie_engine_api::tools::BrushStyle::Star => hcie_engine_api::BrushStyle::Star, - hcie_engine_api::tools::BrushStyle::Glow => hcie_engine_api::BrushStyle::Glow, - hcie_engine_api::tools::BrushStyle::Airbrush => hcie_engine_api::BrushStyle::Airbrush, - hcie_engine_api::tools::BrushStyle::Pencil => hcie_engine_api::BrushStyle::Pencil, - hcie_engine_api::tools::BrushStyle::Crayon => hcie_engine_api::BrushStyle::Crayon, - hcie_engine_api::tools::BrushStyle::WetPaint => hcie_engine_api::BrushStyle::WetPaint, - hcie_engine_api::tools::BrushStyle::InkPen => hcie_engine_api::BrushStyle::InkPen, - hcie_engine_api::tools::BrushStyle::Clouds => hcie_engine_api::BrushStyle::Clouds, - hcie_engine_api::tools::BrushStyle::Dirt => hcie_engine_api::BrushStyle::Dirt, - hcie_engine_api::tools::BrushStyle::Tree => hcie_engine_api::BrushStyle::Tree, - hcie_engine_api::tools::BrushStyle::Bristle => hcie_engine_api::BrushStyle::Bristle, - hcie_engine_api::tools::BrushStyle::Mixer => hcie_engine_api::BrushStyle::Mixer, - hcie_engine_api::tools::BrushStyle::Blender => hcie_engine_api::BrushStyle::Blender, - hcie_engine_api::tools::BrushStyle::Bitmap => hcie_engine_api::BrushStyle::Bitmap, + hcie_engine_api::tools::BrushStyle::Default => { + hcie_engine_api::BrushStyle::Round + } + hcie_engine_api::tools::BrushStyle::Round => { + hcie_engine_api::BrushStyle::Round + } + hcie_engine_api::tools::BrushStyle::Square => { + hcie_engine_api::BrushStyle::Square + } + hcie_engine_api::tools::BrushStyle::HardRound => { + hcie_engine_api::BrushStyle::HardRound + } + hcie_engine_api::tools::BrushStyle::SoftRound => { + hcie_engine_api::BrushStyle::SoftRound + } + hcie_engine_api::tools::BrushStyle::Noise => { + hcie_engine_api::BrushStyle::Noise + } + hcie_engine_api::tools::BrushStyle::Texture => { + hcie_engine_api::BrushStyle::Texture + } + hcie_engine_api::tools::BrushStyle::Spray => { + hcie_engine_api::BrushStyle::Spray + } + hcie_engine_api::tools::BrushStyle::Pen => { + hcie_engine_api::BrushStyle::Pen + } + hcie_engine_api::tools::BrushStyle::Oil => { + hcie_engine_api::BrushStyle::Oil + } + hcie_engine_api::tools::BrushStyle::Charcoal => { + hcie_engine_api::BrushStyle::Charcoal + } + hcie_engine_api::tools::BrushStyle::Leaf => { + hcie_engine_api::BrushStyle::Leaf + } + hcie_engine_api::tools::BrushStyle::Rock => { + hcie_engine_api::BrushStyle::Rock + } + hcie_engine_api::tools::BrushStyle::Meadow => { + hcie_engine_api::BrushStyle::Meadow + } + hcie_engine_api::tools::BrushStyle::Wood => { + hcie_engine_api::BrushStyle::Wood + } + hcie_engine_api::tools::BrushStyle::Watercolor => { + hcie_engine_api::BrushStyle::Watercolor + } + hcie_engine_api::tools::BrushStyle::Calligraphy => { + hcie_engine_api::BrushStyle::Calligraphy + } + hcie_engine_api::tools::BrushStyle::Marker => { + hcie_engine_api::BrushStyle::Marker + } + hcie_engine_api::tools::BrushStyle::Sketch => { + hcie_engine_api::BrushStyle::Sketch + } + hcie_engine_api::tools::BrushStyle::Hatch => { + hcie_engine_api::BrushStyle::Hatch + } + hcie_engine_api::tools::BrushStyle::Star => { + hcie_engine_api::BrushStyle::Star + } + hcie_engine_api::tools::BrushStyle::Glow => { + hcie_engine_api::BrushStyle::Glow + } + hcie_engine_api::tools::BrushStyle::Airbrush => { + hcie_engine_api::BrushStyle::Airbrush + } + hcie_engine_api::tools::BrushStyle::Pencil => { + hcie_engine_api::BrushStyle::Pencil + } + hcie_engine_api::tools::BrushStyle::Crayon => { + hcie_engine_api::BrushStyle::Crayon + } + hcie_engine_api::tools::BrushStyle::WetPaint => { + hcie_engine_api::BrushStyle::WetPaint + } + hcie_engine_api::tools::BrushStyle::InkPen => { + hcie_engine_api::BrushStyle::InkPen + } + hcie_engine_api::tools::BrushStyle::Clouds => { + hcie_engine_api::BrushStyle::Clouds + } + hcie_engine_api::tools::BrushStyle::Dirt => { + hcie_engine_api::BrushStyle::Dirt + } + hcie_engine_api::tools::BrushStyle::Tree => { + hcie_engine_api::BrushStyle::Tree + } + hcie_engine_api::tools::BrushStyle::Bristle => { + hcie_engine_api::BrushStyle::Bristle + } + hcie_engine_api::tools::BrushStyle::Mixer => { + hcie_engine_api::BrushStyle::Mixer + } + hcie_engine_api::tools::BrushStyle::Blender => { + hcie_engine_api::BrushStyle::Blender + } + hcie_engine_api::tools::BrushStyle::Bitmap => { + hcie_engine_api::BrushStyle::Bitmap + } }; tip.size = self.state.tool_configs.brush.size; tip.opacity = self.state.tool_configs.brush.opacity; @@ -1600,25 +1671,20 @@ impl<'a> egui::Widget for CanvasWidget<'a> { }); } - if let Some(tex) = ui - .data(|d| d.get_temp::(ui.id().with("cursor_stamp_tex"))) + if let Some(tex) = + ui.data(|d| d.get_temp::(ui.id().with("cursor_stamp_tex"))) { // Display the stamp preview at the correct diameter. // The texture was rendered at tex_size × tex_size pixels; // display it at diameter_px × diameter_px screen pixels so // the preview matches the actual brush footprint. let display_dim = egui::vec2(diameter_px, diameter_px); - let rect = egui::Rect::from_center_size( - egui::pos2(screen_x, screen_y), - display_dim, - ); + let rect = + egui::Rect::from_center_size(egui::pos2(screen_x, screen_y), display_dim); painter.add(egui::Shape::image( tex.id(), rect, - egui::Rect::from_min_max( - egui::pos2(0.0, 0.0), - egui::pos2(1.0, 1.0), - ), + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE, )); @@ -2551,5 +2617,3 @@ fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Optio } None } - - diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs index a2d8a17..db64947 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs @@ -203,11 +203,14 @@ pub fn render_composition( let src_start = ((y * rw + x0) as usize) * 4; let dst_start = ((y - y0) as usize * region_w) * 4; let row_bytes = region_w * 4; - region_pixels[dst_start..dst_start + row_bytes] - .copy_from_slice(&doc.composite_buffer[src_start..src_start + row_bytes]); + region_pixels[dst_start..dst_start + row_bytes].copy_from_slice( + &doc.composite_buffer[src_start..src_start + row_bytes], + ); } - let region_image = - egui::ColorImage::from_rgba_unmultiplied([region_w, region_h], ®ion_pixels); + let region_image = egui::ColorImage::from_rgba_unmultiplied( + [region_w, region_h], + ®ion_pixels, + ); tex.set_partial([x0 as usize, y0 as usize], region_image, options); } } else { diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index 86df77e..7aa4242 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -1530,9 +1530,11 @@ impl Engine { } /// Get mutable reference to shapes on a vector layer for direct manipulation. + /// Marks the layer dirty so the composite is regenerated on the next render pass. pub fn get_layer_shapes_direct(&mut self, layer_id: u64) -> Option<&mut Vec> { if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { if let LayerData::Vector { ref mut shapes } = layer.data { + layer.dirty = true; return Some(shapes); } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs b/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs index faf4d4c..7c72420 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs @@ -92,7 +92,7 @@ pub enum AiProvider { impl AiProvider { /// Return the default base URL for this provider. - pub fn default_url(&self) -> &str { + pub fn _default_url(&self) -> &str { match self { AiProvider::Ollama => "http://localhost:11434", AiProvider::Claude => "https://api.anthropic.com", @@ -101,7 +101,7 @@ impl AiProvider { } /// Return the display name. - pub fn display_name(&self) -> &str { + pub fn _display_name(&self) -> &str { match self { AiProvider::Ollama => "Ollama", AiProvider::Claude => "Claude", @@ -120,7 +120,7 @@ pub struct AiChatConfig { /// Model name to use for chat completions. pub model: String, /// Maximum conversation turns before auto-truncation. - pub max_turns: u32, + pub _max_turns: u32, /// Whether to display reasoning/thinking content from the LLM. pub reasoning_enabled: bool, /// Whether to send canvas context (dimensions, layers) with each request. @@ -133,7 +133,7 @@ impl Default for AiChatConfig { provider: AiProvider::Ollama, base_url: "http://localhost:11434".to_string(), model: "qwen2.5:latest".to_string(), - max_turns: 20, + _max_turns: 20, reasoning_enabled: false, canvas_context: true, } @@ -156,7 +156,7 @@ pub struct AiChatState { /// Current reasoning/thinking text being streamed. pub current_reasoning: String, /// Available models fetched from the endpoint. - pub available_models: Vec, + pub _available_models: Vec, } impl Default for AiChatState { @@ -168,7 +168,7 @@ impl Default for AiChatState { is_streaming: false, current_response: String::new(), current_reasoning: String::new(), - available_models: Vec::new(), + _available_models: Vec::new(), } } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 0962ae9..49fbcfa 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -152,10 +152,10 @@ pub struct HcieIcedApp { /// Pending automated or keyboard-triggered full-viewport screenshot. pub screenshot_request: Option, /// New Image dialog state. - pub dialog_input: String, - pub dialog_input2: String, - pub show_performance: bool, - pub ui_scale: f32, + pub _dialog_input: String, + pub _dialog_input2: String, + pub _show_performance: bool, + pub _ui_scale: f32, pub modifiers: iced::keyboard::Modifiers, pub dialog_new_name: String, pub dialog_new_width: u32, @@ -280,7 +280,7 @@ pub struct HcieIcedApp { /// Whether the dock profile dialog is visible. pub show_dock_profile_dialog: bool, /// Current UI language for internationalization. - pub language: i18n::Language, + pub _language: i18n::Language, /// Context menu screen position (x, y). None means closed. pub canvas_context_menu: Option<(f32, f32)>, /// Dirty flag set whenever fg/bg/recent colors change, so colors can be @@ -1356,10 +1356,10 @@ impl HcieIcedApp { sub_tools_open: false, sidebar_expanded: settings.panel_layout.sidebar_expanded, slot_last_used: vec![0; 20], - dialog_input: String::new(), - dialog_input2: String::new(), - show_performance: false, - ui_scale: 1.0, + _dialog_input: String::new(), + _dialog_input2: String::new(), + _show_performance: false, + _ui_scale: 1.0, modifiers: iced::keyboard::Modifiers::default(), canvas_context_menu: None, settings: settings.clone(), @@ -1434,7 +1434,7 @@ impl HcieIcedApp { dock_profile_rename_idx: None, dock_profile_rename_buf: String::new(), show_dock_profile_dialog: false, - language: i18n::Language::default(), + _language: i18n::Language::default(), colors_dirty: false, vector_shapes_snapshot: None, vector_drag_last: None, @@ -1582,7 +1582,7 @@ impl HcieIcedApp { /// /// The canvas image is centered in the pane, with `pan_offset` as an /// additional offset from center. This function accounts for both. - fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> { + fn _screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> { let doc = &self.documents[self.active_doc]; let zoom = doc.zoom; let (pane_w, pane_h) = doc.pane_size; @@ -1823,7 +1823,7 @@ impl HcieIcedApp { // structure is no longer valid, and a partial copy would leave zeros in the new buffer. if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) { let w = doc.engine.canvas_width() as usize; - let h = doc.engine.canvas_height() as usize; + let _h = doc.engine.canvas_height() as usize; let [x0, y0, x1, y1] = rect; let x0 = x0 as usize; let y0 = y0 as usize; @@ -5383,6 +5383,20 @@ impl HcieIcedApp { self.documents[self.active_doc].vector_drag_preview = None; return Task::none(); } + + // Frame-skip throttle: skip processing if less than ~16ms since + // the last update to keep the overlay drawing at ~60 FPS and avoid + // flooding the message queue. + let now = std::time::Instant::now(); + let min_interval = std::time::Duration::from_secs_f32(1.0 / 60.0); + if let Some(last) = self.vector_drag_last_render { + if now.duration_since(last) < min_interval { + // Still update pointer tracking even when skipping preview + self.vector_drag_last = Some((x, y)); + return Task::none(); + } + } + let shape = crate::vector_edit::transform_shape( &session, (x, y), @@ -5399,6 +5413,7 @@ impl HcieIcedApp { self.documents[self.active_doc].vector_drag_preview = Some(shape); self.documents[self.active_doc].modified = true; self.vector_drag_last = Some((x, y)); + self.vector_drag_last_render = Some(now); self.vector_drag_last_bounds = Some((x1, y1, x2, y2)); self.vector_drag_last_angle = Some(angle); self.settings.tool_settings.vector_angle = angle.to_degrees(); @@ -5485,12 +5500,32 @@ impl HcieIcedApp { } // ── Vector shape apply / cancel / flip ────── Message::VectorShapeApply => { - // Commit edits: clear snapshot and deselect. - let doc = &mut self.documents[self.active_doc]; - doc.selected_vector_shape = None; - doc.vector_cycle_index = 0; - doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; - doc.vector_drag_preview = None; + // Commit any pending drag preview to the engine before deselecting, + // then clear the snapshot so Cancel cannot revert. + let need_refresh = { + let doc = &mut self.documents[self.active_doc]; + if let Some(shape) = doc.vector_drag_preview.take() { + let shape_idx = doc.selected_vector_shape; + let (x1, y1, x2, y2) = shape.normalized_bounds(); + let angle = shape.angle(); + let layer_id = doc.engine.active_layer_id(); + if let Some(idx) = shape_idx { + doc.engine.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, y2); + doc.engine.set_vector_shape_angle(layer_id, idx, angle); + } + doc.engine.mark_composite_dirty(); + doc.engine.is_composite_dirty() + } else { + false + } + }; + if need_refresh { + self.refresh_composite_if_needed(); + } + self.documents[self.active_doc].selected_vector_shape = None; + self.documents[self.active_doc].vector_cycle_index = 0; + self.documents[self.active_doc].vector_edit_handle = + hcie_engine_api::VectorEditHandle::None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; self.vector_drag_session = None; @@ -5510,6 +5545,7 @@ impl HcieIcedApp { self.vector_drag_session = None; doc.engine.mark_composite_dirty(); self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::VectorShapeFlipH => { @@ -8230,7 +8266,7 @@ mod cycle_one_ux_tests { /// Find the topmost text layer whose rasterized pixels contain the point /// `(x, y)` in canvas coordinates. -fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option { +fn _find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option { let ux = x.round().clamp(0.0, u32::MAX as f32) as u32; let uy = y.round().clamp(0.0, u32::MAX as f32) as u32; let infos = engine.layer_infos(); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs b/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs index 10d590b..87ed131 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs @@ -537,7 +537,7 @@ pub fn import_abr_with_prefix(data: &[u8], prefix: &str) -> Vec { /// /// # Returns /// Optional `BrushPreset` if parsing succeeds. -pub fn import_kpp(data: &[u8], name_hint: &str) -> Option { +pub fn _import_kpp(data: &[u8], name_hint: &str) -> Option { use std::io::Cursor; use zip::ZipArchive; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index 70020f8..b297420 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -60,11 +60,11 @@ struct OverlayProgram { /// Active selection transform (for drawing handles). selection_transform: Option, /// Current transform handle being hovered/dragged. - active_handle: TransformHandle, + _active_handle: TransformHandle, /// Crop tool state (for drawing crop overlay). crop_state: Option, /// Marching ants animation offset (0.0..1.0). - marching_ants_offset: f32, + _marching_ants_offset: f32, /// Current pen pressure for HUD display (0.0..1.0, 1.0 = no tablet). pressure: f32, /// Active tool (drives vector shape preview geometry). @@ -82,11 +82,11 @@ struct OverlayProgram { /// Polygon selection points accumulated during click-by-click (canvas-space). polygon_points: Vec<(u32, u32)>, /// Extracted edges for marching ants rendering. - marching_ants_edges: Option>>, + _marching_ants_edges: Option>>, /// Exact horizontal selected-pixel spans for irregular selection fill. - selection_fill_spans: Option>>, + _selection_fill_spans: Option>>, /// Whether selected pixels use quick-mask red instead of the normal blue tint. - quick_mask: bool, + _quick_mask: bool, /// Active brush diameter in canvas pixels for the footprint cursor. brush_size: f32, /// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2). @@ -891,7 +891,7 @@ impl canvas::Program for OverlayProgram { // arrow_thickness = stroke * 1.8, head_len = arrow_thickness * 3 let dx = x1 - x0; let dy = y1 - y0; - let len = (dx * dx + dy * dy).sqrt().max(1.0); + let _len = (dx * dx + dy * dy).sqrt().max(1.0); let head_angle = dy.atan2(dx); let arrow_angle = std::f32::consts::PI / 6.0; let arrow_thickness = self.zoom; // stroke width in canvas space @@ -1283,6 +1283,51 @@ impl canvas::Program for OverlayProgram { } } + // Draw vector shape preview during a drag (real-time rotated outline). + // The engine composite is NOT updated during drag, so the overlay must + // draw the preview shape to show the user where the shape will end up. + // A solid bright outline is used instead of dashed so the preview is + // clearly visible during fast pointer movements. + if let Some(preview) = self.vector_drag_preview.as_ref() { + let (px1, py1, px2, py2) = preview.normalized_bounds(); + let pang = preview.angle(); + let cx = (px1 + px2) * 0.5; + let cy = (py1 + py2) * 0.5; + let cos_a = pang.cos(); + let sin_a = pang.sin(); + let rotate = |x: f32, y: f32| -> (f32, f32) { + let dx = x - cx; + let dy = y - cy; + (cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a) + }; + let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)]; + let rotated_corners: Vec<(f32, f32)> = + corners.iter().map(|&(x, y)| rotate(x, y)).collect(); + let preview_path = Path::new(|b| { + for (i, &(rx, ry)) in rotated_corners.iter().enumerate() { + let sx = origin_x + rx * self.zoom; + let sy = origin_y + ry * self.zoom; + if i == 0 { + b.move_to(Point::new(sx, sy)); + } else { + b.line_to(Point::new(sx, sy)); + } + } + b.close(); + }); + // Solid bright outline for fast visible feedback + frame.stroke( + &preview_path, + Stroke { + style: canvas::stroke::Style::Solid( + iced::Color::from_rgba(0.2, 0.9, 0.4, 0.95), + ), + width: (2.5 / self.zoom.max(1.0)).max(1.0), + ..Default::default() + }, + ); + } + // Draw vector edit handles for selected shape (blue outline, resize + rotation) if let Some((x1, y1, x2, y2)) = self.selected_vector_bounds { self.draw_vector_edit_handles( @@ -1624,12 +1669,17 @@ pub fn view<'a>( ) } else if let Some(idx) = doc.selected_vector_shape { let layer_id = doc.engine.active_layer_id(); - if let Some(shapes) = doc.engine.active_vector_shapes() { - if let Some(shape) = shapes.get(idx) { + let shapes = doc.engine.active_vector_shapes(); + // Validate that the selected index exists on the current active layer. + // If the layer changed or shapes were reordered, the index may be stale. + if let Some(ref shapes) = shapes { + if idx < shapes.len() { + let shape = &shapes[idx]; let (x1, y1, x2, y2) = shape.normalized_bounds(); let angle = doc.engine.vector_shape_angle(layer_id, idx); (Some((x1, y1, x2, y2)), angle, None) } else { + // Stale index — silently show nothing until the next valid click (None, 0.0, None) } } else { @@ -1648,9 +1698,9 @@ pub fn view<'a>( selection_bounds: doc.selection_bounds, vector_draw: doc.vector_draw, selection_transform: doc.selection_transform.clone(), - active_handle: TransformHandle::None, // TODO: track hover state + _active_handle: TransformHandle::None, // TODO: track hover state crop_state: Some(doc.crop_state.clone()), - marching_ants_offset, + _marching_ants_offset: marching_ants_offset, pressure, active_tool: tool_state.active_tool, star_points, @@ -1659,9 +1709,9 @@ pub fn view<'a>( gradient_drag: doc.gradient_drag, lasso_points: doc.lasso_points.clone(), polygon_points: doc.polygon_points.clone(), - marching_ants_edges: doc.marching_ants_edges.clone(), - selection_fill_spans: doc.selection_fill_spans.clone(), - quick_mask: doc.quick_mask, + _marching_ants_edges: doc.marching_ants_edges.clone(), + _selection_fill_spans: doc.selection_fill_spans.clone(), + _quick_mask: doc.quick_mask, brush_size: tool_state.brush_size, selected_vector_bounds: sel_vec_bounds, selected_vector_angle: sel_vec_angle, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs index 3476dbc..61e21d9 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs @@ -31,6 +31,7 @@ pub enum ColorPickerTarget { /// HSV color representation (Hue, Saturation, Value) #[derive(Debug, Clone, Copy)] +#[allow(dead_code)] pub struct Hsv { pub h: f32, // 0..360 pub s: f32, // 0..1 @@ -38,12 +39,14 @@ pub struct Hsv { } impl Hsv { + #[allow(dead_code)] pub fn to_rgb(&self) -> (u8, u8, u8) { hsv_to_rgb(self.h, self.s, self.v) } } /// Convert RGB to HSV. +#[allow(dead_code)] pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv { let r = r as f32 / 255.0; let g = g as f32 / 255.0; @@ -69,6 +72,7 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv { } /// Convert HSV to RGB. +#[allow(dead_code)] pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { let h = h / 360.0; let (r, g, b) = if s == 0.0 { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/manager.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/manager.rs index 6d09e00..f940746 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/manager.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/manager.rs @@ -15,6 +15,7 @@ pub enum DockRole { /// A movable utility panel. Tool, /// Shell UI that never enters the dock manager. + #[allow(dead_code)] Fixed, } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs index a57db52..444fd73 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs @@ -108,6 +108,7 @@ impl PaneType { } /// Compact readable label used on narrow auto-hide rails. + #[allow(dead_code)] pub fn rail_label(self) -> &'static str { match self { Self::ColorPicker => "Color", diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/i18n.rs b/hcie-iced-app/crates/hcie-iced-gui/src/i18n.rs index 52e08be..f5c15ea 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/i18n.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/i18n.rs @@ -40,6 +40,7 @@ pub enum TranslationKey { /// Returns the translated string for the given language and key. /// /// Uses match arms to return `&'static str` — no allocations at call site. +#[allow(dead_code)] pub fn t(lang: Language, key: TranslationKey) -> &'static str { match lang { Language::Turkish => match key { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs b/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs index ddb02fc..2253981 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs @@ -4,3 +4,4 @@ pub mod cli; pub mod raster; pub mod selection; pub mod vector_edit; +pub mod widgets; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index 2381938..cac8196 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -24,6 +24,7 @@ mod sidebar; mod theme; mod vector_edit; mod viewer; +mod widgets; use app::HcieIcedApp; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs index 254f3ce..aa95088 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs @@ -778,7 +778,7 @@ pub fn view( .padding([2, 8]); row![lbl, toggle_btn].spacing(4) }, - { text("Gradient stops configured via color picker").size(10) }, + text("Gradient stops configured via color picker").size(10), ] .spacing(2) .into() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/history.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/history.rs index 34948f3..02baa87 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/history.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/history.rs @@ -7,7 +7,7 @@ use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; -use iced::widget::{column, container, horizontal_rule, scrollable, text}; +use iced::widget::{column, container, scrollable, text}; use iced::{Element, Length}; /// Build the history panel element. diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index cc6923e..84c9d53 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -229,6 +229,7 @@ fn menu_item_row( /// All menu definitions matching Photopea's menu structure exactly. #[derive(Clone)] struct MenuDef { + #[allow(dead_code)] label: String, items: Vec, } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs index 799868d..63d6fb9 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs @@ -10,10 +10,9 @@ use crate::color_picker::ColorPickerTarget; use crate::panels::styles; use crate::panels::typography::{BODY, SECTION}; use crate::theme::ThemeColors; +use crate::widgets::plain_slider; use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape}; -use iced::widget::{ - button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, -}; +use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, text}; use iced::{Element, Length}; /// Human-readable label for a shape at a given index. @@ -200,11 +199,15 @@ fn layer_info_section<'a>( layer_id: u64, _colors: ThemeColors, ) -> Element<'a, Message> { - let opacity_slider = slider(0.0..=1.0, opacity, move |v| { - Message::LayerSetOpacity(layer_id, v) - }) - .step(0.01) - .width(Length::Fill); + let opacity_slider = plain_slider( + "Opacity", + opacity, + 0.0..=1.0, + 0.01, + "%", + 0, + move |v| Message::LayerSetOpacity(layer_id, v), + ); column![ text("Layer Info").size(SECTION), @@ -213,18 +216,11 @@ fn layer_info_section<'a>( text(name).size(BODY), ] .spacing(4), - row![ - text("Opacity").size(BODY).width(Length::Fixed(58.0)), - text(format!("{:.0}%", opacity * 100.0)) - .size(BODY) - .width(Length::Fixed(36.0)), - ] - .spacing(4), opacity_slider, row![ text("Visible").size(BODY).width(Length::Fixed(58.0)), checkbox("", visible) - .on_toggle(move |v| Message::LayerToggleVisibility(layer_id)) + .on_toggle(move |_v| Message::LayerToggleVisibility(layer_id)) .size(11), ] .spacing(4) @@ -289,44 +285,42 @@ fn vector_select_properties<'a>( text("Shape Properties").size(SECTION), row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4), row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4), - row![ - text("Stroke").size(BODY), - text(format!("{:.1}", stroke)).size(BODY) - ] - .spacing(4), - slider(0.0..=50.0, stroke, move |v| Message::VectorStrokeChanged(v)) - .step(0.5) - .width(Length::Fill), - row![ - text("Opacity").size(BODY), - text(format!("{:.0}%", opacity * 100.0)).size(BODY) - ] - .spacing(4), - slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged( - v - )) - .step(0.01) - .width(Length::Fill), - row![ - text("Hardness").size(BODY), - text(format!("{:.0}%", hardness * 100.0)).size(BODY) - ] - .spacing(4), - slider(0.0..=1.0, hardness, move |v| { - Message::GeometryHardnessChanged(v) - }) - .step(0.01) - .width(Length::Fill), - row![ - text("Angle").size(BODY), - text(format!("{:.0}°", vector_angle)).size(BODY) - ] - .spacing(4), - slider(-180.0..=180.0, vector_angle, move |v| { - Message::VectorAngleChanged(v) - }) - .step(1.0) - .width(Length::Fill), + plain_slider( + "Stroke", + stroke, + 0.0..=50.0, + 0.5, + "px", + 1, + move |v| Message::VectorStrokeChanged(v), + ), + plain_slider( + "Opacity", + opacity, + 0.0..=1.0, + 0.01, + "%", + 0, + move |v| Message::VectorOpacityChanged(v), + ), + plain_slider( + "Hardness", + hardness, + 0.0..=1.0, + 0.01, + "%", + 0, + move |v| Message::GeometryHardnessChanged(v), + ), + plain_slider( + "Angle", + vector_angle, + -180.0..=180.0, + 1.0, + "°", + 0, + move |v| Message::VectorAngleChanged(v), + ), row![ text("Fill").size(BODY), checkbox("", filled) @@ -380,22 +374,24 @@ fn vector_tool_properties<'a>( ) -> Element<'a, Message> { let mut col = column![ text("Vector Shape").size(11), - row![ - text("Stroke").size(10), - text(format!("{:.1}", stroke)).size(10) - ] - .spacing(4), - slider(0.0..=50.0, stroke, |v| Message::VectorStrokeChanged(v)) - .step(0.5) - .width(Length::Fill), - row![ - text("Opacity").size(10), - text(format!("{:.0}%", opacity * 100.0)).size(10) - ] - .spacing(4), - slider(0.0..=1.0, opacity, |v| Message::VectorOpacityChanged(v)) - .step(0.01) - .width(Length::Fill), + plain_slider( + "Stroke", + stroke, + 0.0..=50.0, + 0.5, + "px", + 1, + |v| Message::VectorStrokeChanged(v), + ), + plain_slider( + "Opacity", + opacity, + 0.0..=1.0, + 0.01, + "%", + 0, + |v| Message::VectorOpacityChanged(v), + ), row![ text("Fill").size(10), checkbox("", fill) @@ -426,45 +422,37 @@ fn vector_tool_properties<'a>( // Shape-specific controls match tool { Tool::VectorRect => { - col = col.push( - row![ - text("Radius").size(10), - text(format!("{:.1}", radius)).size(10) - ] - .spacing(4), - ); - col = col.push( - slider(0.0..=100.0, radius, |v| Message::VectorRadiusChanged(v)) - .step(1.0) - .width(Length::Fill), - ); + col = col.push(plain_slider( + "Radius", + radius, + 0.0..=100.0, + 1.0, + "px", + 1, + |v| Message::VectorRadiusChanged(v), + )); } Tool::VectorStar => { - col = col.push( - row![ - text("Points").size(10), - text(format!("{}", points)).size(10) - ] - .spacing(4), - ); - col = col.push( - slider(3.0..=20.0, points as f32, |v| { - Message::VectorPointsChanged(v as u32) - }) - .step(1.0) - .width(Length::Fill), - ); + col = col.push(plain_slider( + "Points", + points as f32, + 3.0..=20.0, + 1.0, + "", + 0, + |v| Message::VectorPointsChanged(v as u32), + )); } Tool::VectorPolygon => { - col = col - .push(row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4)); - col = col.push( - slider(3.0..=12.0, sides as f32, |v| { - Message::VectorSidesChanged(v as u32) - }) - .step(1.0) - .width(Length::Fill), - ); + col = col.push(plain_slider( + "Sides", + sides as f32, + 3.0..=12.0, + 1.0, + "", + 0, + |v| Message::VectorSidesChanged(v as u32), + )); } _ => {} } @@ -488,32 +476,35 @@ fn brush_properties<'a>( hardness: f32, _colors: ThemeColors, ) -> Element<'a, Message> { - let size_slider = slider(1.0..=200.0, size, |v| Message::BrushSizeChanged(v)) - .step(1.0) - .width(Length::Fill); - let opacity_slider = slider(0.0..=1.0, opacity, |v| Message::BrushOpacityChanged(v)) - .step(0.01) - .width(Length::Fill); - let hardness_slider = slider(0.0..=1.0, hardness, |v| Message::BrushHardnessChanged(v)) - .step(0.01) - .width(Length::Fill); - column![ text("Brush Properties").size(11), - row![text("Size").size(10), text(format!("{:.0}", size)).size(10)].spacing(4), - size_slider, - row![ - text("Opacity").size(10), - text(format!("{:.0}%", opacity * 100.0)).size(10) - ] - .spacing(4), - opacity_slider, - row![ - text("Hardness").size(10), - text(format!("{:.0}%", hardness * 100.0)).size(10) - ] - .spacing(4), - hardness_slider, + plain_slider( + "Size", + size, + 1.0..=200.0, + 1.0, + "px", + 0, + |v| Message::BrushSizeChanged(v), + ), + plain_slider( + "Opacity", + opacity, + 0.0..=1.0, + 0.01, + "%", + 0, + |v| Message::BrushOpacityChanged(v), + ), + plain_slider( + "Hardness", + hardness, + 0.0..=1.0, + 0.01, + "%", + 0, + |v| Message::BrushHardnessChanged(v), + ), ] .spacing(4) .into() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/styles.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/styles.rs index 9a1c83d..ead858d 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/styles.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/styles.rs @@ -106,6 +106,7 @@ pub fn panel_background(colors: ThemeColors) -> iced::widget::container::Style { } /// Panel header — #333333 (Photopea header color). +#[allow(dead_code)] pub fn panel_header(colors: ThemeColors) -> iced::widget::container::Style { iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_active)), diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs index 1cbcb0f..2ff1293 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs @@ -53,9 +53,9 @@ pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_wid pub fn view<'a>( doc_name: &'a str, modified: bool, - zoom: f32, - canvas_w: u32, - canvas_h: u32, + _zoom: f32, + _canvas_w: u32, + _canvas_h: u32, colors: ThemeColors, active_menu: Option, ) -> Element<'a, Message> { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs index d4718db..2ab33ec 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs @@ -13,7 +13,7 @@ use crate::app::{Message, ToolState}; use crate::panels::styles; use crate::theme::ThemeColors; use hcie_engine_api::Tool; -use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input}; +use iced::widget::{button, container, row, slider, text, text_input}; use iced::{Element, Length}; /// Build the Photopea-style tool options bar. @@ -25,8 +25,8 @@ use iced::{Element, Length}; /// - Move: Auto-Select /// - Text: Font, Size, Color /// - Gradient: Mode, Opacity -pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> { - let tool_name = text(tool_name_label(tool_state.active_tool)) +pub fn _view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> { + let tool_name = text(_tool_name_label(tool_state.active_tool)) .size(11) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(colors.text_primary), @@ -202,7 +202,7 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M } /// Get the display name for a tool. -fn tool_name_label(tool: Tool) -> &'static str { +fn _tool_name_label(tool: Tool) -> &'static str { match tool { Tool::Move => "Move Tool", Tool::Select => "Rectangle Select", @@ -226,6 +226,7 @@ fn tool_name_label(tool: Tool) -> &'static str { } /// A small checkbox-style label (visual only, for display). +#[allow(dead_code)] fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> { let mark = if checked { "☑" } else { "☐" }; let label_owned = label.to_string(); @@ -236,6 +237,7 @@ fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> { } /// A small flat button for tool options. +#[allow(dead_code)] fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> { let bg_hover = colors.bg_hover; let bg_panel = colors.bg_panel; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs index fe0f8b4..e64fa65 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs @@ -17,7 +17,7 @@ use crate::settings::AppSettings; use crate::theme::ThemeColors; use hcie_engine_api::Tool; use iced::widget::{ - button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input, + button, column, container, horizontal_rule, row, scrollable, slider, text, text_input, }; use iced::{Element, Length}; @@ -309,10 +309,10 @@ fn text_settings( let draft_angle = text_draft.map(|d| d.angle).unwrap_or(ts2.text_angle); let draft_color = text_draft.map(|d| d.color).unwrap_or(fg_color); let draft_content = text_draft.map(|d| d.content.clone()).unwrap_or_default(); - let alignment = text_draft + let _alignment = text_draft .map(|d| format!("{:?}", d.alignment)) .unwrap_or_else(|| "Left".to_string()); - let orientation = text_draft + let _orientation = text_draft .map(|d| format!("{:?}", d.orientation)) .unwrap_or_else(|| "Horizontal".to_string()); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs index a8704c6..0cb09c2 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs @@ -231,6 +231,7 @@ fn svg_btn( ) } +#[allow(dead_code)] fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> { let c = colors; let l = label.to_string(); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs index 03c3c85..ac8c1aa 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs @@ -1,9 +1,3 @@ pub mod executor; pub mod parser; pub mod types; - -pub use executor::execute_actions; -pub use parser::parse_dsl_script; -pub use parser::parse_error_line; -pub use parser::suggest_fix; -pub use parser::DEFAULT_SCRIPT; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs index ff71880..cd8ecd7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs @@ -93,6 +93,7 @@ pub fn extract_masked_pixels( /// * `floating` - The floating selection transform containing pixels to composite /// * `canvas_w` - Width of the canvas in pixels /// * `canvas_h` - Height of the canvas in pixels +#[allow(dead_code)] pub fn composite_floating_to_layer( layer_pixels: &mut [u8], floating: &SelectionTransform, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs index 838bf3d..b737208 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs @@ -339,6 +339,7 @@ impl TransformHandle { ]; /// Get cursor icon hint for this handle. + #[allow(dead_code)] pub fn cursor_hint(&self) -> &'static str { match self { TransformHandle::None => "default", diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs index a660a33..32abba1 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs @@ -22,6 +22,7 @@ use iced::{Element, Length}; pub struct ToolSlot { pub tools: &'static [Tool], pub label: &'static str, + #[allow(dead_code)] pub icon: &'static str, } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/theme.rs b/hcie-iced-app/crates/hcie-iced-gui/src/theme.rs index bafa99e..50d1f2a 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/theme.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/theme.rs @@ -39,6 +39,7 @@ impl ThemeState { /// **Returns:** The preset used to produce the current color tokens. /// **Logic & Workflow:** Reads the preset from the same lock as its resolved colors. /// **Side Effects / Dependencies:** Briefly locks shared theme state. + #[allow(dead_code)] pub fn preset(&self) -> ThemePreset { self.state.lock().unwrap().0 } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs index 676b340..e361bff 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs @@ -183,6 +183,7 @@ impl ViewerState { } /// Get subdirectories of a path, cached to avoid repeated disk reads. + #[allow(dead_code)] pub fn get_subdirs(&mut self, path: &Path) -> &[PathBuf] { if !self.subdirs_cache.contains_key(path) { let mut dirs = Vec::new(); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs index d674ccf..36d6784 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs @@ -154,6 +154,7 @@ fn thumbnail_card( } /// Count how many thumbnails need loading. +#[allow(dead_code)] pub fn count_unloaded(state: &ViewerState) -> usize { state .images diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/widgets/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/widgets/mod.rs new file mode 100644 index 0000000..e150471 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/widgets/mod.rs @@ -0,0 +1,10 @@ +//! Reusable iced widgets for the HCIE GUI. +//! +//! Contains the keyboard-editable `PlainSlider`, mirroring the egui version +//! used elsewhere in the project. + +#[allow(deprecated)] +pub mod plain_slider; + +#[allow(deprecated)] +pub use plain_slider::plain_slider; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs b/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs new file mode 100644 index 0000000..fe43806 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs @@ -0,0 +1,334 @@ +//! A keyboard-editable numeric slider widget for iced. +//! +//! Mirrors the egui `PlainSlider` used in the egui GUI so the iced +//! Properties panel supports direct value entry from the keyboard. +//! The widget is implemented as an iced `Component`, which keeps the +//! transient edit state inside the widget instead of in the application +//! state. This trait is deprecated in iced 0.13, but it is still the +//! simplest way to bundle local state with a reusable widget. + +#![allow(deprecated)] + +use iced::widget::{column, component, mouse_area, row, slider, text, text_input, Component}; +use iced::{Element, Length, Renderer, Theme}; +use std::sync::Arc; + +/// Internal state for a `PlainSlider` instance. +#[derive(Debug, Default, Clone)] +pub(crate) struct State { + /// Whether the user is currently typing a value. + editing: bool, + /// Current contents of the text input while editing. + buffer: String, +} + +/// Internal events produced by the widget UI. +#[derive(Debug, Clone)] +pub(crate) enum Event { + /// The slider thumb was dragged to a new value. + SliderChanged(f32), + /// The value label was clicked; switch to text-input mode. + StartEdit, + /// The text input contents changed. + InputChanged(String), + /// The user pressed Enter or otherwise submitted the typed value. + Submit, +} + +/// A numeric slider with an editable value label. +/// +/// The value label displays the current value with a suffix such as `%` +/// or `px`. Clicking the label replaces it with a text input so the user +/// can type an exact value. +#[derive(Clone)] +pub(crate) struct PlainSlider { + /// Human-readable label shown to the left of the value. + label: String, + /// Current value. The parent application owns the authoritative value + /// and passes it back on each frame. + value: f32, + /// Inclusive numeric range the value is allowed to occupy. + range: std::ops::RangeInclusive, + /// Step size used by the slider thumb. + step: f32, + /// Suffix shown after the value (e.g. "%", "px"). + suffix: String, + /// Number of decimal places to display. + decimals: usize, + /// Width of the value label / text input area. + input_width: f32, + /// Callback invoked whenever the value changes, either by dragging + /// the slider or by submitting the text input. + #[allow(clippy::type_complexity)] + on_change: Arc Message>, +} + +impl std::fmt::Debug for PlainSlider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlainSlider") + .field("label", &self.label) + .field("value", &self.value) + .field("range", &self.range) + .field("step", &self.step) + .field("suffix", &self.suffix) + .field("decimals", &self.decimals) + .field("input_width", &self.input_width) + .field("on_change", &"Arc Message>") + .finish() + } +} + +impl PlainSlider { + /// Creates a new editable slider. + /// + /// # Arguments + /// * `label` - short label shown next to the value. + /// * `value` - current numeric value. + /// * `range` - inclusive allowed range. + /// * `step` - slider step. + /// * `suffix` - unit suffix (e.g. "%", "px"). + /// * `decimals` - number of decimals to display. + /// * `on_change` - callback producing a message for the parent. + pub fn new( + label: impl Into, + value: f32, + range: std::ops::RangeInclusive, + step: f32, + suffix: impl Into, + decimals: usize, + on_change: impl Fn(f32) -> Message + 'static, + ) -> Self { + Self { + label: label.into(), + value, + range, + step, + suffix: suffix.into(), + decimals, + input_width: 48.0, + on_change: Arc::new(on_change), + } + } + + /// Sets the width of the editable value area. + #[allow(dead_code)] + pub fn input_width(mut self, width: f32) -> Self { + self.input_width = width; + self + } + + /// Formats a numeric value for display, including the suffix. + /// + /// Percentage values are stored normalized (0..1) and displayed + /// multiplied by 100. Other units are displayed as-is. + fn format_value(&self, value: f32) -> String { + if self.suffix == "%" { + format!("{:.*}{}", self.decimals, value * 100.0, self.suffix) + } else if self.suffix.is_empty() { + format!("{:.*}", self.decimals, value) + } else { + format!("{:.*} {}", self.decimals, value, self.suffix) + } + } + + /// Parses a raw string back into a numeric value, taking `%` into account. + fn parse_value(&self, raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let numeric = trimmed + .trim_end_matches(&self.suffix) + .trim() + .replace(',', "."); + let parsed: f32 = numeric.parse().ok()?; + let value = if self.suffix == "%" { parsed / 100.0 } else { parsed }; + Some(value.clamp(*self.range.start(), *self.range.end())) + } +} + +impl Component for PlainSlider +where + Message: Clone, +{ + type State = State; + type Event = Event; + + fn update(&mut self, state: &mut State, event: Event) -> Option { + match event { + Event::SliderChanged(new_value) => { + // Update the local value so the displayed text follows the thumb. + self.value = new_value.clamp(*self.range.start(), *self.range.end()); + Some((self.on_change)(self.value)) + } + Event::StartEdit => { + state.editing = true; + state.buffer = self.format_value(self.value); + None + } + Event::InputChanged(new_value) => { + state.buffer = new_value; + None + } + Event::Submit => { + if let Some(v) = self.parse_value(&state.buffer) { + self.value = v; + state.editing = false; + state.buffer.clear(); + Some((self.on_change)(v)) + } else { + state.editing = false; + state.buffer.clear(); + None + } + } + } + } + + fn view(&self, state: &State) -> Element<'_, Event> { + let value_text = self.format_value(self.value); + + let slider_control = slider(self.range.clone(), self.value, Event::SliderChanged) + .step(self.step) + .width(Length::Fill); + + if state.editing { + let input = text_input("", &state.buffer) + .width(Length::Fixed(self.input_width)) + .size(12) + .on_input(Event::InputChanged) + .on_submit(Event::Submit); + + let editor = row![input] + .align_y(iced::Alignment::Center) + .spacing(4); + + column![ + row![ + text(&self.label).size(12).width(Length::Shrink), + editor, + ] + .spacing(4) + .align_y(iced::Alignment::Center), + slider_control, + ] + .spacing(2) + .into() + } else { + let value_label = mouse_area( + text(value_text) + .size(12) + .width(Length::Fixed(self.input_width)) + .align_x(iced::alignment::Horizontal::Right), + ) + .on_press(Event::StartEdit); + + column![ + row![ + text(&self.label).size(12).width(Length::Shrink), + value_label, + ] + .spacing(4) + .align_y(iced::Alignment::Center), + slider_control, + ] + .spacing(2) + .into() + } + } +} + +/// Convenience constructor that wraps `PlainSlider` in a lazy component element. +/// +/// Use this from panel code in place of the standard `slider` widget. +pub fn plain_slider( + label: impl Into, + value: f32, + range: std::ops::RangeInclusive, + step: f32, + suffix: impl Into, + decimals: usize, + on_change: impl Fn(f32) -> Message + 'static, +) -> Element<'static, Message> +where + Message: 'static + Clone, +{ + component(PlainSlider::new(label, value, range, step, suffix, decimals, on_change)) +} + +#[cfg(test)] +mod tests { + use super::PlainSlider; + use iced::widget::Component; + + fn dummy(_: f32) -> () {} + + #[test] + fn formats_percentage_without_decimals() { + let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); + assert_eq!(slider.format_value(0.5), "50%"); + assert_eq!(slider.format_value(1.0), "100%"); + assert_eq!(slider.format_value(0.0), "0%"); + } + + #[test] + fn formats_pxx_with_decimals() { + let slider = PlainSlider::new("Stroke", 37.0, 0.0..=50.0, 0.5, "px", 1, dummy); + assert_eq!(slider.format_value(37.0), "37.0 px"); + } + + #[test] + fn parses_percentage_input() { + let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); + assert!((slider.parse_value("75%").unwrap() - 0.75).abs() < 1e-6); + assert!((slider.parse_value("75").unwrap() - 0.75).abs() < 1e-6); + assert_eq!(slider.parse_value(""), None); + } + + #[test] + fn parses_px_input() { + let slider = PlainSlider::new("Stroke", 10.0, 0.0..=50.0, 0.5, "px", 1, dummy); + assert!((slider.parse_value("25 px").unwrap() - 25.0).abs() < 1e-6); + assert!((slider.parse_value("25").unwrap() - 25.0).abs() < 1e-6); + } + + #[test] + fn clamps_out_of_range_input() { + let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); + assert_eq!(slider.parse_value("150"), Some(1.0)); + assert_eq!(slider.parse_value("-10"), Some(0.0)); + } + + #[test] + fn update_emits_on_slider_drag() { + let mut slider = PlainSlider::new("Size", 10.0, 1.0..=200.0, 1.0, "px", 0, dummy); + let mut state = super::State::default(); + let message = slider.update(&mut state, super::Event::SliderChanged(42.0)); + assert_eq!(message, Some(())); + assert!((slider.value - 42.0).abs() < 1e-6); + } + + #[test] + fn update_commits_valid_text_input() { + let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); + let mut state = super::State::default(); + slider.update(&mut state, super::Event::StartEdit); + slider.update(&mut state, super::Event::InputChanged("80".to_string())); + let message = slider.update(&mut state, super::Event::Submit); + assert!((slider.value - 0.8).abs() < 1e-6); + assert_eq!(message, Some(())); + assert!(!state.editing); + } + + #[test] + fn update_ignores_invalid_text_input() { + let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); + let mut state = super::State::default(); + slider.update(&mut state, super::Event::StartEdit); + slider.update(&mut state, super::Event::InputChanged("abc".to_string())); + let message = slider.update(&mut state, super::Event::Submit); + assert!((slider.value - 0.5).abs() < 1e-6); + assert_eq!(message, None); + assert!(!state.editing); + } +} diff --git a/hcie-io/examples/analyze_bevel_emboss.rs b/hcie-io/examples/analyze_bevel_emboss.rs index fd77957..5ca31cc 100644 --- a/hcie-io/examples/analyze_bevel_emboss.rs +++ b/hcie-io/examples/analyze_bevel_emboss.rs @@ -80,7 +80,7 @@ fn main() { &alpha, CANVAS, CANVAS, s.depth, s.size, s.soften, 120.0, s.altitude, &dir_enum, &hcie_fx::types::Technique::Smooth, &style_enum, - best_lb, best_hl, best_sh, 1.0, + best_lb, best_hl, best_sh, 1.0, 1.0, ); let px = npix(); diff --git a/hcie-io/examples/inspect_diff.rs b/hcie-io/examples/inspect_diff.rs index 473a0ab..4324b6a 100644 --- a/hcie-io/examples/inspect_diff.rs +++ b/hcie-io/examples/inspect_diff.rs @@ -1,5 +1,3 @@ -use image::GenericImageView; - fn main() { let ref_img = image::open("_images/_psd_stil_test/test_2/Emboss.png") .unwrap() diff --git a/hcie-io/examples/tune_mae_bevel_emboss_kra_grid.rs b/hcie-io/examples/tune_mae_bevel_emboss_kra_grid.rs index fa9fbcb..ecc89b2 100644 --- a/hcie-io/examples/tune_mae_bevel_emboss_kra_grid.rs +++ b/hcie-io/examples/tune_mae_bevel_emboss_kra_grid.rs @@ -127,7 +127,7 @@ fn main() { &rect_alpha, unsafe { CANVAS }, unsafe { CANVAS }, s.depth, s.size, s.soften, s.angle, 30.0, &dir_enum, &hcie_fx::types::Technique::Smooth, &style_enum, - lighting_blur, hl_scale, sh_scale, profile_exp, + lighting_blur, hl_scale, sh_scale, profile_exp, 1.0, ); let px = npix(); diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..7f368e9 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,46 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-:2026.1