diff --git a/.kilo/plans/1784583086898-iced-history-panels-slider-visual-plan.md b/.kilo/plans/1784583086898-iced-history-panels-slider-visual-plan.md new file mode 100644 index 0000000..315b646 --- /dev/null +++ b/.kilo/plans/1784583086898-iced-history-panels-slider-visual-plan.md @@ -0,0 +1,71 @@ +# Iced History, Panel, Visual, and Slider Improvements + +## Scope And Decisions + +- Target the Iced workspace under `hcie-iced-app/`; use `hcie-egui-app/` only as a behavioral/design reference. +- Visual scope: shared design system plus main chrome (title/menu/sidebar/dock/dialogs) and History, Custom Shapes, and Brushes & Tips. Canvas rendering and AI/script plugin internals are out of scope. +- Brush filters: `All`, `Drawing`, `Painting`, `Effects`, `Custom`, and `Imported`; combine built-in/default presets, every engine `BrushStyle`, and imported ABR presets. New custom-preset authoring/rename/delete is out of scope. +- Slider input: typing a numeric character while hovering enters edit mode; Enter commits, Escape or focus loss cancels. Clicking the value area also enters edit mode. +- First shape on a non-vector layer is one atomic `Add Vector Shape` history action; Undo removes the auto-created vector layer and shape together, Redo restores both. +- Preserve all current uncommitted work and protected GPU/dirty-region behavior. + +## Implementation Plan + +1. **Make vector creation an atomic engine history operation.** + - Temporarily unlock `hcie-document` and `hcie-engine-api` with the repository scripts; relock them after edits. + - In `hcie-document/src/lib.rs`, add a documented vector-layer creation path that constructs the final vector layer (including its first shape) before recording history. Record one layer-add action whose captured redo layer is already `LayerType::Vector` and contains the shape; label it `Add Vector Shape`. + - In `hcie-engine-api/src/lib.rs::Engine::add_vector_shape`, retain `push_vector_snapshot` for an existing vector layer. Replace the current non-vector path (`add_layer` followed by mutation) with the atomic document operation so no intermediate raster-layer history entry is created. + - Ensure undo/redo/jump leave `active_layer` valid after the generated layer is removed/restored; clamp or restore selection through the existing engine history wrapper rather than changing protected compositing caches. + - In `hcie-iced-gui/src/app.rs::VectorDrawEnd`, keep the single `add_vector_shape` call, then refresh cached layers/history through the existing composite refresh path. Add a small helper for refreshing `cached_history` if tests show no composite dirty flag after a history-only transition. + - Add engine tests for: first shape on raster layer adds exactly one named entry; second shape on existing vector layer adds exactly one entry; undo/redo restores layer count, layer type, and shape count; cancelled/zero-result custom shape creates no entry. Add an Iced update-level regression test confirming `cached_history` exposes the new entry after `VectorDrawEnd`. + +2. **Convert Custom Shapes from a list to a responsive grid.** + - Refactor `hcie-iced-gui/src/panels/custom_shapes.rs` to build cards through `iced::widget::responsive`, deriving columns from available width rather than fixing a one-column list. + - Use a theme-safe minimum cell width around 72–84 px, 4–6 px gaps, at least two columns when the pane permits, and row chunking that recomputes whenever dock width changes. + - Keep SVG preview, readable label, selected accent border, click behavior, refresh button, path hint, and empty state. Truncate/wrap long labels without expanding a cell beyond the grid width. + - Extract/test a pure `grid_column_count(available_width, cell_min, gap)` helper for narrow, normal, and wide panes. + +3. **Build a complete, filterable Brushes & Tips catalog.** + - Add a public GUI-side `BrushCategory`/filter type and transient `brush_category` state to `HcieIcedApp`, plus `Message::BrushCategorySelected` and `Message::BrushPresetSelected` as needed. + - Replace the partial 22-entry `BRUSH_PRESETS` list in `panels/brushes.rs` with a canonical GUI catalog covering every currently exported `BrushStyle` variant, including inactive/missing styles such as Round, Square, Rock, Meadow, Wood, WetPaint, Dirt, Tree, Bristle, Mixer, Blender, and Bitmap. + - Initialize built-in runtime presets from `BrushPreset::all_defaults()`, merge `ToolState.imported_brushes`, deduplicate by preset ID, and pass the resulting slices plus active category into `brushes::view` from `dock/view.rs`. + - Port egui’s category mapping: Drawing = Basic/Sketch and drawing styles; Painting = Paint/Ink and painting styles; Effects = Texture/Effect/Nature/Mixer and effect styles; Custom matches custom presets; Imported matches Imported and Imported (ABR). Keep all six filter controls visible even when a category is empty. + - Make category controls functional and visually selected; show an explicit empty-filter message. Imported ABR completion should switch to or visibly populate `Imported` without dropping existing entries. + - Use responsive rows for style/preset cells, cached previews, selected styling, and tooltips. Selecting a preset applies its complete `BrushTip`/style data; selecting a style-only cell updates the style while retaining current size/opacity/hardness unless the egui reference explicitly applies a preset. + - Add tests for exhaustive category mapping, imported/custom matching, deduplication, and filter state transitions. + +4. **Introduce a shared Photoshop-inspired surface system.** + - Extend `iced-panel-adapter/src/theme.rs::ThemeColors` with explicit recessed/elevated surface and shadow/highlight tokens, or documented derivation helpers, for every theme preset. Do not hard-code dark-only colors; use `is_light` and active theme tokens. + - In `panels/styles.rs`, add reusable styles for recessed controls, raised cards/buttons, focused cards, pane headers, menus, and dialogs. Use a restrained 1 px highlight edge, low-contrast lower border, 2–6 px shadow blur, and 2–6 px corner radii; avoid heavy shadows on dense lists. + - Apply the shared styles to title/menu/sidebar/dock headers and cards, common dialog surfaces, History rows, Custom Shape cards, and Brush filter/preset cells. Preserve the independent opaque surface layers already required to stay above the custom canvas shader. + - Match the supplied Photoshop reference’s hierarchy: darkest workspace, mid-level panels, slightly raised headers/controls, compact typography, clear selected states. The “soft 3D” treatment should come from elevation/highlight/shadow, not large gradients or oversized rounded cards. + - Capture before/after screenshots for the full viewport and each changed panel; verify both one dark and one light theme. + +5. **Replace `plain_slider` with an event-aware custom Iced widget.** + - Rewrite `hcie-iced-gui/src/widgets/plain_slider.rs` as a documented `iced::advanced::Widget` instead of the deprecated `Component`, because hover-triggered keyboard entry requires direct event/cursor handling. + - Store editing/buffer state in the widget tree; support click/drag on the track, step increments in the right spinner area, hover-started numeric typing, click-to-edit, Enter commit, Escape/blur cancel, range clamping, decimal/comma parsing, suffix formatting, normalized percentages, and event capture while editing so global shortcuts do not fire. + - Render a 22 px compact progress-bar control modeled on egui: theme-derived recessed track, subtle vertical gradient on the filled accent portion, narrow value handle, centered label/value, right-side up/down spinner, focus/hover border, and lightweight hover shadow. Derive every color from `ThemeColors`. + - Preserve a builder/convenience API for label, range, step, suffix, decimals, width, optional logarithmic mapping, and optional snap steps. Keep parent-owned values and emit messages only for committed typing or pointer/spinner changes. + - Add focused unit/widget tests for parsing, formatting, clamping, hover-to-edit, Enter/Escape/blur, percentage conversion, drag endpoints, spinner steps, logarithmic mapping, and theme-derived style output. + +6. **Migrate every Iced slider call site.** + - Replace existing `plain_slider` calls to pass `ThemeColors` and adopt the new API in dialogs (`new_image`, `confirm`, `adjustments`), properties, filters, layers, tool settings, and any other current users. + - Replace direct `iced::widget::slider` calls in `color_picker.rs`, `toolbar.rs`, `tool_options.rs`, `geometry.rs`, `layer_styles.rs`, `brushes.rs`, `text_editor.rs`, and `status_bar.rs`, preserving ranges, steps, units, integer conversions, and compact layout requirements. + - Update `iced-panel-adapter/src/lib.rs` so engine-described `WidgetDescription::Slider` also uses the same control and receives active theme colors through its render API. + - Use compact labels for RGB/HSL/zoom sliders and avoid duplicated external labels where the new control already displays one. Confirm no raw slider imports/calls remain with a repository grep, except internal implementation details or explicitly documented non-numeric controls. + +7. **Validation and regression protection.** + - Run formatting and `git diff --check` without reverting unrelated work. + - Run `cargo check -p hcie-iced-gui`, `cargo test -p hcie-iced-gui --tests`, and the relevant document/engine API tests. + - Because locked engine crates are touched, run `cargo test -p hcie-engine-api --test visual_regression` and `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture`; confirm no protected pooling, cache, dirty-region, tile, or shader code changed. + - Exercise vector creation on raster, vector, and locked-vector starting layers; confirm each successful creation adds one visible History row and Undo/Redo is exact. + - Use built-in screenshot flows for `History`, `Custom Shapes`, and `Brushes & Tips`, plus full-viewport/dialog captures. Verify responsive grids at narrow/default/wide dock widths, all six brush filters, imported ABR visibility, dark/light themes, slider typing and dragging, and no transparency regression over the shader canvas. + +## Risks And Guardrails + +- The history fix crosses locked engine boundaries; use the sanctioned unlock/lock scripts and stage only intended files. +- Do not emulate atomic vector history solely in the GUI or push a pixel snapshot; that would leave redo with the wrong layer type/content. +- Keep brush preview generation cached; generating all style/preset thumbnails every frame would regress panel performance. +- Hover typing must only start when the pointer is inside the slider and the text event is numeric; otherwise tool shortcuts remain unaffected. +- Maintain the protected Iced shader layering and partial-upload mechanisms. New shadows/surfaces must be ordinary GUI layers above the shader, not changes to `shader_canvas.rs`. +- No persisted-data migration is required: brush category and slider edit buffers are transient. Existing settings, imported brush behavior, dock layouts, and theme preset serialization remain compatible. diff --git a/Cargo.lock b/Cargo.lock index 96dcdf1..cc79088 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2576,6 +2576,10 @@ dependencies = [ "serde", ] +[[package]] +name = "hcie-build-info" +version = "0.1.0" + [[package]] name = "hcie-color" version = "0.1.0" @@ -2712,6 +2716,7 @@ dependencies = [ "egui_extras", "env_logger", "evdev", + "hcie-build-info", "hcie-engine-api", "image 0.25.10", "log", @@ -2749,6 +2754,7 @@ dependencies = [ "env_logger", "evdev", "futures-util", + "hcie-build-info", "hcie-engine-api", "iced", "iced-panel-adapter", diff --git a/Cargo.toml b/Cargo.toml index 9e88240..558238b 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "hcie-build-info", "hcie-protocol", "hcie-color", "hcie-blend", @@ -37,7 +38,12 @@ members = [ "tools/panel-tuner", ] +[workspace.package] +version = "0.1.0" +edition = "2021" + [workspace.dependencies] +hcie-build-info = { path = "hcie-build-info" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..0ce0583 --- /dev/null +++ b/build.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Canonical HCIE workspace build entry point. + +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -eq 0 ]]; then + set -- --workspace +fi + +"${ROOT_DIR}/scripts/cargo-with-build-id.sh" build "$@" diff --git a/hcie-build-info/Cargo.toml b/hcie-build-info/Cargo.toml new file mode 100644 index 0000000..33d0a53 --- /dev/null +++ b/hcie-build-info/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "hcie-build-info" +version.workspace = true +edition.workspace = true +build = "build.rs" + +[lib] +crate-type = ["rlib"] diff --git a/hcie-build-info/build.rs b/hcie-build-info/build.rs new file mode 100644 index 0000000..d40392b --- /dev/null +++ b/hcie-build-info/build.rs @@ -0,0 +1,58 @@ +//! Read-only build metadata generation for HCIE GUI packages. +//! +//! **Purpose:** Publishes the build number prepared by `scripts/cargo-with-build-id.sh` to every +//! frontend without mutating watched Cargo inputs from inside a build script. +//! **Logic & Workflow:** Uses the wrapper-provided override when present, otherwise reads the +//! canonical repository `build.id`, validates it, and emits synchronized compile-time variables. +//! **Side Effects / Dependencies:** Reads one repository file and writes only Cargo directives. + +use std::fs; +use std::io; +use std::path::PathBuf; + +/// Reads and validates the canonical or wrapper-provided build identifier. +/// +/// **Returns:** The current build ID as `u64`. +/// **Side Effects / Dependencies:** Reads `build.id` when no override is provided. +fn build_id() -> u64 { + if let Ok(value) = std::env::var("HCIE_BUILD_ID_OVERRIDE") { + return value + .parse::() + .expect("HCIE_BUILD_ID_OVERRIDE must be an unsigned integer"); + } + + let manifest_dir = PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is required"), + ); + let canonical = manifest_dir + .parent() + .expect("hcie-build-info must live directly under the repository root") + .join("build.id"); + match fs::read_to_string(&canonical) { + Ok(value) => value.trim().parse::().unwrap_or_else(|error| { + panic!("invalid integer in {}: {error}", canonical.display()) + }), + Err(error) if error.kind() == io::ErrorKind::NotFound => 0, + Err(error) => panic!("failed to read {}: {error}", canonical.display()), + } +} + +/// Publishes synchronized build metadata to the library crate. +fn main() { + let manifest_dir = PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is required"), + ); + let canonical = manifest_dir + .parent() + .expect("hcie-build-info must live directly under the repository root") + .join("build.id"); + println!("cargo:rerun-if-changed={}", canonical.display()); + println!("cargo:rerun-if-env-changed=HCIE_BUILD_ID_OVERRIDE"); + + let build_id = build_id(); + let base_version = std::env::var("CARGO_PKG_VERSION").expect("package version is required"); + println!("cargo:rustc-env=HCIE_BUILD_ID={build_id}"); + println!( + "cargo:rustc-env=HCIE_BUILD_VERSION={base_version}+build.{build_id}" + ); +} diff --git a/hcie-build-info/src/lib.rs b/hcie-build-info/src/lib.rs new file mode 100644 index 0000000..5e97208 --- /dev/null +++ b/hcie-build-info/src/lib.rs @@ -0,0 +1,35 @@ +//! Compile-time HCIE build identity shared by all GUI implementations. +//! +//! **Purpose:** Exposes the synchronized product version generated by `build.rs` without requiring +//! each frontend or dependency to read mutable files at runtime. +//! **Logic & Workflow:** Cargo injects immutable environment values while compiling this crate; +//! consumers reference the constants directly. +//! **Side Effects / Dependencies:** None at runtime. + +/// Canonical integer build identifier generated from the repository `build.id` file. +pub const BUILD_ID: &str = env!("HCIE_BUILD_ID"); + +/// Full synchronized product version, for example `0.1.0+build.988`. +pub const VERSION: &str = env!("HCIE_BUILD_VERSION"); + +/// Parses the generated build ID for APIs that need a numeric value. +/// +/// **Returns:** The compile-time build ID as `u64`. +/// **Side Effects / Dependencies:** Panics only if the build script emitted invalid metadata, +/// which is prevented by its integer validation. +pub fn build_id() -> u64 { + BUILD_ID + .parse() + .expect("HCIE_BUILD_ID must be a valid unsigned integer") +} + +#[cfg(test)] +mod tests { + use super::{build_id, BUILD_ID, VERSION}; + + #[test] + fn generated_version_contains_the_same_build_id() { + assert!(build_id() > 0); + assert!(VERSION.ends_with(&format!("+build.{BUILD_ID}"))); + } +} diff --git a/hcie-document/src/lib.rs b/hcie-document/src/lib.rs index 59e1b1b..70b9038 100644 --- a/hcie-document/src/lib.rs +++ b/hcie-document/src/lib.rs @@ -1,6 +1,9 @@ #![allow(dead_code)] -use hcie_protocol::{BlendMode, Layer, LayerData, LayerInfo, LayerStyle, LayerType, MAX_UNDO_STEPS_DEFAULT, VectorShape}; use hcie_history::{HistoryManager, UndoableAction}; +use hcie_protocol::{ + BlendMode, Layer, LayerData, LayerInfo, LayerStyle, LayerType, VectorShape, + MAX_UNDO_STEPS_DEFAULT, +}; use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_LAYER_ID: AtomicU64 = AtomicU64::new(1); @@ -66,7 +69,8 @@ impl Document { pub fn add_layer(&mut self, name: impl Into) -> u64 { let name_str = name.into(); - let new_layer = Layer::new_transparent(name_str.clone(), self.canvas_width, self.canvas_height); + let new_layer = + Layer::new_transparent(name_str.clone(), self.canvas_width, self.canvas_height); let new_id = next_layer_id(); let idx = self.layers.len(); self.layers.push(new_layer); @@ -87,8 +91,53 @@ impl Document { new_id } + /// Creates a vector layer containing its first shape as one history transaction. + /// + /// **Purpose:** Avoids recording the intermediate transparent raster layer that would be + /// produced by calling [`Document::add_layer`] before converting the layer to vector data. + /// **Logic & Workflow:** Builds the complete vector layer, assigns a stable ID, appends it, + /// marks the document dirty, and records the final layer in a single `LayerAddAction` so Undo + /// removes the layer and Redo restores both the layer type and its initial shape. + /// **Arguments:** `name` is the generated layer label, `shape` is the first vector shape, and + /// `description` is the user-facing history entry. + /// **Returns:** The stable ID assigned to the new vector layer. + /// **Side Effects / Dependencies:** Mutates layers, active-layer selection, dirty state, and + /// the document history stack. + pub fn add_vector_layer_with_shape( + &mut self, + name: impl Into, + shape: VectorShape, + description: impl Into, + ) -> u64 { + let name = name.into(); + let mut layer = Layer::new_transparent(name, self.canvas_width, self.canvas_height); + let layer_id = next_layer_id(); + layer.id = layer_id; + layer.layer_type = LayerType::Vector; + layer.data = LayerData::Vector { + shapes: vec![shape], + }; + layer.dirty = true; + + let layer_index = self.layers.len(); + self.layers.push(layer); + self.active_layer = layer_index; + self.composite_dirty = true; + self.modified = true; + + self.history.push(Box::new(LayerAddAction { + layer_index, + layer: self.layers[layer_index].clone(), + description: description.into(), + })); + + layer_id + } + pub fn delete_layer(&mut self, idx: usize) { - if idx >= self.layers.len() { return; } + if idx >= self.layers.len() { + return; + } let layer = &self.layers[idx]; let action = LayerDeleteAction { @@ -128,9 +177,13 @@ impl Document { } pub fn set_layer_visible(&mut self, idx: usize, visible: bool) { - if idx >= self.layers.len() { return; } + if idx >= self.layers.len() { + return; + } let old_visible = self.layers[idx].visible; - if old_visible == visible { return; } + if old_visible == visible { + return; + } self.layers[idx].visible = visible; self.layers[idx].dirty = true; @@ -155,11 +208,13 @@ impl Document { pub fn undo(&mut self) { self.history.undo(&mut self.layers); + self.normalize_active_layer(); self.composite_dirty = true; } pub fn redo(&mut self) { self.history.redo(&mut self.layers); + self.normalize_active_layer(); self.composite_dirty = true; } @@ -185,16 +240,29 @@ impl Document { pub fn jump_to_history(&mut self, idx: i32) { self.history.jump_to(idx, &mut self.layers); + self.normalize_active_layer(); self.composite_dirty = true; } + /// Keeps active-layer selection valid after history actions add or remove layers. + /// + /// **Purpose:** Layer history actions can invalidate the selected index when Undo removes the + /// active layer. **Logic & Workflow:** Clamps the index to the final available layer, using + /// zero when no layer exists. **Arguments/Returns:** None. **Side Effects:** Updates only + /// `active_layer`. + fn normalize_active_layer(&mut self) { + self.active_layer = self.active_layer.min(self.layers.len().saturating_sub(1)); + } + pub fn push_vector_snapshot( &mut self, layer_idx: usize, before_shapes: Option>, description: String, ) { - if layer_idx >= self.layers.len() { return; } + if layer_idx >= self.layers.len() { + return; + } let layer = &self.layers[layer_idx]; let after_shapes = if let LayerData::Vector { shapes } = &layer.data { Some(shapes.clone()) @@ -343,7 +411,9 @@ impl Document { } pub fn move_layer(&mut self, from: usize, to: usize) { - if from >= self.layers.len() || to >= self.layers.len() || from == to { return; } + if from >= self.layers.len() || to >= self.layers.len() || from == to { + return; + } let layer = self.layers.remove(from); let insert_idx = if to > from { to } else { to }; self.layers.insert(insert_idx, layer); @@ -356,7 +426,9 @@ impl Document { let y1 = y.min(self.canvas_height); let w1 = w.min(self.canvas_width - x1); let h1 = h.min(self.canvas_height - y1); - if w1 == 0 || h1 == 0 { return; } + if w1 == 0 || h1 == 0 { + return; + } for layer in &mut self.layers { let mut new_pixels = vec![0u8; (w1 * h1 * 4) as usize]; @@ -367,7 +439,8 @@ impl Document { if src_x < layer.width && src_y < layer.height { let src_i = ((src_y * layer.width + src_x) * 4) as usize; let dst_i = ((ly * w1 + lx) * 4) as usize; - new_pixels[dst_i..dst_i + 4].copy_from_slice(&layer.pixels[src_i..src_i + 4]); + new_pixels[dst_i..dst_i + 4] + .copy_from_slice(&layer.pixels[src_i..src_i + 4]); } } } @@ -383,7 +456,9 @@ impl Document { } pub fn resize_canvas(&mut self, w: u32, h: u32) { - if w == 0 || h == 0 { return; } + if w == 0 || h == 0 { + return; + } for layer in &mut self.layers { let mut new_pixels = vec![0u8; (w * h * 4) as usize]; let copy_w = w.min(layer.width); @@ -392,9 +467,8 @@ impl Document { let src_start = (y * layer.width) as usize * 4; let dst_start = (y * w) as usize * 4; let row_bytes = (copy_w * 4) as usize; - new_pixels[dst_start..dst_start + row_bytes].copy_from_slice( - &layer.pixels[src_start..src_start + row_bytes] - ); + new_pixels[dst_start..dst_start + row_bytes] + .copy_from_slice(&layer.pixels[src_start..src_start + row_bytes]); } layer.pixels = new_pixels; layer.width = w; @@ -441,7 +515,9 @@ impl Document { pub fn invert_selection(&mut self) { if let Some(mask) = &mut self.selection_mask { - for val in mask.iter_mut() { *val = 255 - *val; } + for val in mask.iter_mut() { + *val = 255 - *val; + } } else { self.select_all(); } @@ -449,7 +525,10 @@ impl Document { pub fn get_mask_at(&self, x: u32, y: u32) -> u8 { match &self.selection_mask { - Some(mask) => mask.get((y * self.canvas_width + x) as usize).copied().unwrap_or(255), + Some(mask) => mask + .get((y * self.canvas_width + x) as usize) + .copied() + .unwrap_or(255), None => 255, } } @@ -477,32 +556,34 @@ impl Document { }) } - pub fn all_layer_ids(&self) -> Vec { self.layers.iter().map(|l| l.id).collect() } pub fn layer_infos(&self) -> Vec { - self.layers.iter().map(|l| LayerInfo { - id: l.id, - name: l.name.clone(), - layer_type: l.layer_type, - width: l.width, - height: l.height, - visible: l.visible, - opacity: l.opacity, - blend_mode: l.blend_mode, - locked: l.locked, - parent_id: l.parent_id, - clipping_mask: l.clipping_mask, - collapsed: l.collapsed, - has_vector_shapes: matches!(&l.data, LayerData::Vector { .. }), - shape_count: match &l.data { - LayerData::Vector { shapes } => shapes.len(), - _ => 0, - }, - has_effects: !l.effects.is_empty() || !l.styles.is_empty(), - }).collect() + self.layers + .iter() + .map(|l| LayerInfo { + id: l.id, + name: l.name.clone(), + layer_type: l.layer_type, + width: l.width, + height: l.height, + visible: l.visible, + opacity: l.opacity, + blend_mode: l.blend_mode, + locked: l.locked, + parent_id: l.parent_id, + clipping_mask: l.clipping_mask, + collapsed: l.collapsed, + has_vector_shapes: matches!(&l.data, LayerData::Vector { .. }), + shape_count: match &l.data { + LayerData::Vector { shapes } => shapes.len(), + _ => 0, + }, + has_effects: !l.effects.is_empty() || !l.styles.is_empty(), + }) + .collect() } } @@ -551,7 +632,12 @@ struct LayerDeleteAction { impl UndoableAction> for LayerDeleteAction { fn undo(&mut self, layers: &mut Vec) { - let mut restored = Layer::from_rgba(self.name.clone(), self.width, self.height, self.pixels.clone()); + let mut restored = Layer::from_rgba( + self.name.clone(), + self.width, + self.height, + self.pixels.clone(), + ); restored.layer_type = self.layer_type; restored.opacity = self.opacity; restored.blend_mode = self.blend_mode; @@ -599,8 +685,11 @@ impl UndoableAction> for DrawAction { let src_start = (row * rw * 4) as usize; let dst_start = (((y0 + row) * dst_w + x0) * 4) as usize; let len = (rw * 4) as usize; - if src_start + len <= self.before_pixels.len() && dst_start + len <= layer.pixels.len() { - layer.pixels[dst_start..dst_start + len].copy_from_slice(&self.before_pixels[src_start..src_start + len]); + if src_start + len <= self.before_pixels.len() + && dst_start + len <= layer.pixels.len() + { + layer.pixels[dst_start..dst_start + len] + .copy_from_slice(&self.before_pixels[src_start..src_start + len]); } } } else { @@ -608,7 +697,9 @@ impl UndoableAction> for DrawAction { } layer.dirty = true; if let Some(ref shapes) = self.before_shapes { - layer.data = LayerData::Vector { shapes: shapes.clone() }; + layer.data = LayerData::Vector { + shapes: shapes.clone(), + }; } } } @@ -621,8 +712,11 @@ impl UndoableAction> for DrawAction { let src_start = (row * rw * 4) as usize; let dst_start = (((y0 + row) * dst_w + x0) * 4) as usize; let len = (rw * 4) as usize; - if src_start + len <= self.after_pixels.len() && dst_start + len <= layer.pixels.len() { - layer.pixels[dst_start..dst_start + len].copy_from_slice(&self.after_pixels[src_start..src_start + len]); + if src_start + len <= self.after_pixels.len() + && dst_start + len <= layer.pixels.len() + { + layer.pixels[dst_start..dst_start + len] + .copy_from_slice(&self.after_pixels[src_start..src_start + len]); } } } else { @@ -630,7 +724,9 @@ impl UndoableAction> for DrawAction { } layer.dirty = true; if let Some(ref shapes) = self.after_shapes { - layer.data = LayerData::Vector { shapes: shapes.clone() }; + layer.data = LayerData::Vector { + shapes: shapes.clone(), + }; } } } @@ -663,4 +759,3 @@ impl UndoableAction> for LayerVisibilityAction { self.description.clone() } } - diff --git a/hcie-egui-app/Makefile b/hcie-egui-app/Makefile index e9cd0b1..d04e807 100644 --- a/hcie-egui-app/Makefile +++ b/hcie-egui-app/Makefile @@ -14,25 +14,25 @@ dev: @echo "▶ HCIE Egui — Dev Mode" - cargo run -p hcie-gui-egui + ../scripts/cargo-with-build-id.sh run -p hcie-gui-egui # ── Production Builds ────────────────────────────────────────────────────── build: @echo "▶ HCIE Egui — Release Build" - cargo build --release -p hcie-gui-egui + ../scripts/cargo-with-build-id.sh build --release -p hcie-gui-egui @echo "→ Binary: target/release/hcie-gui" # ── Validation ───────────────────────────────────────────────────────────── check: - cargo check --workspace + ../scripts/cargo-with-build-id.sh check --workspace test: - cargo test --workspace + ../scripts/cargo-with-build-id.sh test --workspace lint: - cargo clippy --workspace -- --deny warnings + ../scripts/cargo-with-build-id.sh clippy --workspace -- --deny warnings help: @echo "HCIE-Rust v3 — Egui Commands:" diff --git a/hcie-egui-app/crates/egui-panel-adapter/Cargo.toml b/hcie-egui-app/crates/egui-panel-adapter/Cargo.toml index 57b237b..6b50cf9 100644 --- a/hcie-egui-app/crates/egui-panel-adapter/Cargo.toml +++ b/hcie-egui-app/crates/egui-panel-adapter/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "egui-panel-adapter" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [dependencies] hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-egui-app/crates/egui-panel-ai-chat/Cargo.toml b/hcie-egui-app/crates/egui-panel-ai-chat/Cargo.toml index 111da25..6e8e9e2 100644 --- a/hcie-egui-app/crates/egui-panel-ai-chat/Cargo.toml +++ b/hcie-egui-app/crates/egui-panel-ai-chat/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "egui-panel-ai-chat" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [dependencies] hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-egui-app/crates/egui-panel-ai-script/Cargo.toml b/hcie-egui-app/crates/egui-panel-ai-script/Cargo.toml index ef35599..3d698dc 100644 --- a/hcie-egui-app/crates/egui-panel-ai-script/Cargo.toml +++ b/hcie-egui-app/crates/egui-panel-ai-script/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "egui-panel-ai-script" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [dependencies] hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-egui-app/crates/egui-panel-filters/Cargo.toml b/hcie-egui-app/crates/egui-panel-filters/Cargo.toml index e0e57bc..5d1a063 100644 --- a/hcie-egui-app/crates/egui-panel-filters/Cargo.toml +++ b/hcie-egui-app/crates/egui-panel-filters/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "egui-panel-filters" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [dependencies] hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-egui-app/crates/egui-panel-script/Cargo.toml b/hcie-egui-app/crates/egui-panel-script/Cargo.toml index d7f69cf..b52b022 100644 --- a/hcie-egui-app/crates/egui-panel-script/Cargo.toml +++ b/hcie-egui-app/crates/egui-panel-script/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "egui-panel-script" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [dependencies] hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-egui-app/crates/hcie-gui-egui/Cargo.toml b/hcie-egui-app/crates/hcie-gui-egui/Cargo.toml index 34f17b4..bf7684b 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/Cargo.toml +++ b/hcie-egui-app/crates/hcie-gui-egui/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hcie-gui-egui" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [[bin]] name = "hcie-gui" @@ -12,6 +12,7 @@ default = ["tablet-evdev"] tablet-evdev = ["dep:evdev"] [dependencies] +hcie-build-info = { workspace = true } hcie-engine-api = { path = "../../../hcie-engine-api" } egui-panel-adapter = { path = "../egui-panel-adapter" } egui-panel-filters = { path = "../egui-panel-filters" } 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 93c5519..9baf0e1 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 @@ -315,11 +315,10 @@ impl HcieApp { /// /// ### Logic & Workflow /// 1. Registers image loaders and custom typography fonts to the egui context. - /// 2. Reads and increments a build run counter stored in `build.id`. - /// 3. Reconstructs settings, tool configurations, colors, and AI chat state from persistent local storage. - /// 4. Loads the previously saved dock state containing tab placements and panel splits. - /// 5. Restores the persisted column widths (`hcie_panel_widths`) to maintain user adjustments across launches. - /// 6. Instantiates the UI and state manager. + /// 2. Reconstructs settings, tool configurations, colors, and AI chat state from persistent local storage. + /// 3. Loads the previously saved dock state containing tab placements and panel splits. + /// 4. Restores the persisted column widths (`hcie_panel_widths`) to maintain user adjustments across launches. + /// 5. Instantiates the UI and state manager. /// /// ### Arguments /// * `cc` - A reference to eframe's creation context containing settings and storage access. @@ -328,7 +327,6 @@ impl HcieApp { /// An initialized instance of `HcieApp` ready to be run by the eframe event loop. /// /// ### Side Effects / Dependencies - /// - Performs file reads/writes on `build.id`. /// - Connects to the local persistent storage engine. pub fn new( cc: &eframe::CreationContext<'_>, @@ -342,17 +340,6 @@ impl HcieApp { // and widget can read its tuned pixel values from the very first frame. shell::gui_layout::init_global(); - // Bump build run counter stored in a local file. - if let Ok(content) = std::fs::read_to_string("build.id") { - if let Ok(n) = content.trim().parse::() { - let _ = std::fs::write("build.id", (n + 1).to_string()); - } else { - let _ = std::fs::write("build.id", "1"); - } - } else { - let _ = std::fs::write("build.id", "1"); - } - // Initialize column widths with default values before attempting to load them from storage let mut last_window_width = 0.0; let mut left_col1_w = 36.0; // Tools column is locked at 36px diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs index fcb9543..cab4a89 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs @@ -95,7 +95,7 @@ fn get_icon_tint(ctx: &egui::Context) -> Color32 { /// /// **Logic & Workflow:** Reads the active document name from `app.active_doc_ref()` /// and falls back to `Untitled`. The full version is taken from -/// `env!("CARGO_PKG_VERSION")` of `hcie-gui-egui`. +/// the shared `hcie-build-info` crate, which includes the synchronized build ID. /// /// **Arguments:** /// * `app`: Reference to application state for the active document name. @@ -106,7 +106,7 @@ fn build_title(app: &HcieApp) -> String { .active_doc_ref() .map(|d| d.name.clone()) .unwrap_or_else(|| "Untitled".to_string()); - let version = env!("CARGO_PKG_VERSION"); + let version = hcie_build_info::VERSION; format!("HCIE v{version} — {doc}") } diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index 27abb01..1a29fdc 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -29,6 +29,8 @@ use hcie_tile::TiledLayer; use std::path::Path; use std::sync::Mutex; +pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry}; +pub use crate::svg_editor::{SvgEditable, SvgNode}; pub use hcie_blend::Adjustment; pub use hcie_brush_engine::presets::BrushPreset; pub use hcie_protocol::thumbnail_nearest; @@ -41,8 +43,6 @@ pub use hcie_protocol::{ FilterType, LayerData, LayerInfo, LayerStyle, LayerType, LineCap, ModulePanel, ToolConfigs, VectorEditHandle, VectorShape, WidgetDescription, ZOOM_MAX, ZOOM_MIN, }; -pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry}; -pub use crate::svg_editor::{SvgEditable, SvgNode}; pub mod brush { pub use hcie_brush_engine::{BrushStyle, BrushTip}; } @@ -556,8 +556,7 @@ impl Engine { cached_selection_mask: None, pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), shape_catalog: { - let base = dirs::data_local_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")); + let base = dirs::data_local_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let shapes_dir = base.join("hcie").join("shapes"); crate::shape_catalog::ShapeCatalog::new(shapes_dir) }, @@ -850,9 +849,7 @@ impl Engine { let idx = y * w + x; if mask[idx] == 0 { let pidx = idx * 4; - if pidx + 3 < layer.pixels.len() - && pidx + 3 < before_pixels.len() - { + if pidx + 3 < layer.pixels.len() && pidx + 3 < before_pixels.len() { layer.pixels[pidx] = before_pixels[pidx]; layer.pixels[pidx + 1] = before_pixels[pidx + 1]; layer.pixels[pidx + 2] = before_pixels[pidx + 2]; @@ -899,9 +896,7 @@ impl Engine { let idx = y * w + x; if mask[idx] == 0 { let pidx = idx * 4; - if pidx + 3 < layer.pixels.len() - && pidx + 3 < saved_before.len() - { + if pidx + 3 < layer.pixels.len() && pidx + 3 < saved_before.len() { layer.pixels[pidx] = saved_before[pidx]; layer.pixels[pidx + 1] = saved_before[pidx + 1]; layer.pixels[pidx + 2] = saved_before[pidx + 2]; @@ -1346,23 +1341,14 @@ impl Engine { return; } } - // Active layer is NOT vector — create a NEW vector layer (matching V2 behavior) - let new_id = self - .document - .add_layer(&format!("Vector {}", self.document.layers.len())); - if let Some(idx) = self.document.layer_index_by_id(new_id) { - // Set as vector layer - if let Some(layer) = self.document.get_layer_by_id_mut(new_id) { - layer.data = LayerData::Vector { - shapes: vec![shape], - }; - layer.layer_type = LayerType::Vector; - layer.dirty = true; - self.document.composite_dirty = true; - self.document.modified = true; - } - self.document.set_active_layer(idx); - } + // Create the final vector layer before recording history. This keeps the first shape and + // its auto-created layer in one atomic Undo/Redo entry instead of capturing an empty + // intermediate raster layer. + self.document.add_vector_layer_with_shape( + format!("Vector {}", self.document.layers.len()), + shape, + "Add Vector Shape", + ); } pub fn get_svg_source(&self, layer_id: u64) -> Option<&[u8]> { diff --git a/hcie-engine-api/tests/vector_creation_history.rs b/hcie-engine-api/tests/vector_creation_history.rs new file mode 100644 index 0000000..cea3a59 --- /dev/null +++ b/hcie-engine-api/tests/vector_creation_history.rs @@ -0,0 +1,93 @@ +//! Regression coverage for atomic vector-shape creation history. +//! +//! **Purpose:** Ensures the first vector shape and its auto-created vector layer form one exact +//! Undo/Redo transaction, while later shapes on the same layer continue to use vector snapshots. +//! **Logic & Workflow:** Creates shapes through the public `Engine` API, inspects history metadata, +//! and exercises Undo/Redo against layer and shape counts. +//! **Side Effects / Dependencies:** Uses only in-memory engine documents. + +use hcie_engine_api::{Engine, LayerType, VectorShape}; + +/// Builds a deterministic rectangle for history tests. +/// +/// **Arguments:** `offset` shifts the rectangle so repeated shapes remain distinct. +/// **Returns:** A filled vector rectangle with stable styling. +/// **Side Effects / Dependencies:** None. +fn rectangle(offset: f32) -> VectorShape { + VectorShape::Rect { + name: String::new(), + x1: 10.0 + offset, + y1: 12.0 + offset, + x2: 60.0 + offset, + y2: 48.0 + offset, + stroke: 2.0, + color: [10, 20, 30, 255], + fill: true, + fill_color: [40, 50, 60, 255], + radius: 0.0, + angle: 0.0, + opacity: 1.0, + hardness: 0.5, + } +} + +#[test] +fn first_vector_shape_is_one_atomic_layer_transaction() { + let mut engine = Engine::new(128, 96); + let layers_before = engine.get_layer_count(); + let history_before = engine.history_len(); + + engine.add_vector_shape(rectangle(0.0)); + + assert_eq!(engine.get_layer_count(), layers_before + 1); + assert_eq!(engine.history_len(), history_before + 1); + assert_eq!( + engine.history_description(history_before).as_deref(), + Some("Add Vector Shape") + ); + assert_eq!( + engine + .get_layer_info(engine.active_layer_id()) + .map(|info| info.layer_type), + Some(LayerType::Vector) + ); + assert_eq!( + engine.active_vector_shapes().map(|shapes| shapes.len()), + Some(1) + ); + + assert!(engine.undo()); + assert_eq!(engine.get_layer_count(), layers_before); + + assert!(engine.redo()); + assert_eq!(engine.get_layer_count(), layers_before + 1); + assert!(engine + .layer_infos() + .iter() + .any(|info| info.layer_type == LayerType::Vector && info.shape_count == 1)); +} + +#[test] +fn later_vector_shapes_each_add_one_snapshot() { + let mut engine = Engine::new(128, 96); + engine.add_vector_shape(rectangle(0.0)); + let history_before = engine.history_len(); + + engine.add_vector_shape(rectangle(8.0)); + + assert_eq!(engine.history_len(), history_before + 1); + assert_eq!( + engine.active_vector_shapes().map(|shapes| shapes.len()), + Some(2) + ); + assert!(engine.undo()); + assert_eq!( + engine.active_vector_shapes().map(|shapes| shapes.len()), + Some(1) + ); + assert!(engine.redo()); + assert_eq!( + engine.active_vector_shapes().map(|shapes| shapes.len()), + Some(2) + ); +} diff --git a/hcie-history/src/lib.rs b/hcie-history/src/lib.rs index 9fb588a..3ce946c 100644 --- a/hcie-history/src/lib.rs +++ b/hcie-history/src/lib.rs @@ -47,8 +47,9 @@ impl HistoryManager { } pub fn redo(&mut self, state: &mut S) { - if self.current_index >= 0 && (self.current_index as usize) + 1 < self.entries.len() { - let idx = (self.current_index + 1) as usize; + let next_index = self.current_index + 1; + if next_index >= 0 && (next_index as usize) < self.entries.len() { + let idx = next_index as usize; self.entries[idx].redo(state); self.current_index += 1; } @@ -59,7 +60,8 @@ impl HistoryManager { } pub fn can_redo(&self) -> bool { - self.current_index >= 0 && (self.current_index as usize + 1) < self.entries.len() + let next_index = self.current_index + 1; + next_index >= 0 && (next_index as usize) < self.entries.len() } pub fn len(&self) -> usize { @@ -98,3 +100,40 @@ impl HistoryManager { } } } + +#[cfg(test)] +mod tests { + use super::{HistoryManager, UndoableAction}; + + /// Minimal additive action used to verify history cursor boundaries. + struct Add(i32); + + impl UndoableAction for Add { + fn undo(&mut self, state: &mut i32) { + *state -= self.0; + } + + fn redo(&mut self, state: &mut i32) { + *state += self.0; + } + + fn description(&self) -> String { + "add".to_string() + } + } + + #[test] + fn first_entry_can_redo_from_pre_history_cursor() { + let mut history = HistoryManager::new(10); + let mut state = 5; + history.push(Box::new(Add(5))); + + history.undo(&mut state); + assert_eq!(state, 0); + assert!(history.can_redo()); + + history.redo(&mut state); + assert_eq!(state, 5); + assert_eq!(history.current_index(), 0); + } +} diff --git a/hcie-history/tests/history_state.rs b/hcie-history/tests/history_state.rs index 1f16d2a..1c65276 100644 --- a/hcie-history/tests/history_state.rs +++ b/hcie-history/tests/history_state.rs @@ -41,10 +41,14 @@ fn test_history_push_undo_redo() { let mut layers = vec![make_layer()]; let mut hist = HistoryManager::>::new(10); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [0;4], new_color: [255, 0, 0, 255], + layer_idx: 0, + old_color: [0; 4], + new_color: [255, 0, 0, 255], })); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [255, 0, 0, 255], new_color: [0, 255, 0, 255], + layer_idx: 0, + old_color: [255, 0, 0, 255], + new_color: [0, 255, 0, 255], })); assert!(hist.can_undo()); assert!(!hist.can_redo()); @@ -62,7 +66,9 @@ fn test_history_undo_twice_is_stable() { let mut layers = vec![make_layer()]; let mut hist = HistoryManager::>::new(10); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [0; 4], new_color: [255; 4], + layer_idx: 0, + old_color: [0; 4], + new_color: [255; 4], })); hist.undo(&mut layers); hist.undo(&mut layers); @@ -74,7 +80,9 @@ fn test_history_redo_twice_is_stable() { let mut layers = vec![make_layer()]; let mut hist = HistoryManager::>::new(10); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [0; 4], new_color: [255; 4], + layer_idx: 0, + old_color: [0; 4], + new_color: [255; 4], })); hist.undo(&mut layers); hist.redo(&mut layers); @@ -101,16 +109,22 @@ fn test_history_new_action_truncates_redo() { let mut layers = vec![make_layer()]; let mut hist = HistoryManager::>::new(10); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [0; 4], new_color: [1; 4], + layer_idx: 0, + old_color: [0; 4], + new_color: [1; 4], })); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [1; 4], new_color: [2; 4], + layer_idx: 0, + old_color: [1; 4], + new_color: [2; 4], })); hist.undo(&mut layers); assert!(hist.can_redo()); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [1; 4], new_color: [3; 4], + layer_idx: 0, + old_color: [1; 4], + new_color: [3; 4], })); assert!(!hist.can_redo(), "new action should truncate redo stack"); } @@ -120,17 +134,26 @@ fn test_history_jump_to() { let mut layers = vec![make_layer()]; let mut hist = HistoryManager::>::new(10); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [0; 4], new_color: [1; 4], + layer_idx: 0, + old_color: [0; 4], + new_color: [1; 4], })); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [1; 4], new_color: [2; 4], + layer_idx: 0, + old_color: [1; 4], + new_color: [2; 4], })); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [2; 4], new_color: [3; 4], + layer_idx: 0, + old_color: [2; 4], + new_color: [3; 4], })); hist.jump_to(-1, &mut layers); assert!(!hist.can_undo(), "jump to -1 should be at bottom"); - assert!(!hist.can_redo(), "jump to -1: no redo at initial state"); + assert!( + hist.can_redo(), + "jump to -1 should allow replaying the first entry" + ); assert_eq!(layers[0].get_pixel(0, 0), [0; 4]); } @@ -138,7 +161,9 @@ fn test_history_jump_to() { fn test_entry_description() { let mut hist = HistoryManager::>::new(10); hist.push(Box::new(SetPixelAction { - layer_idx: 0, old_color: [0; 4], new_color: [255; 4], + layer_idx: 0, + old_color: [0; 4], + new_color: [255; 4], })); assert_eq!(hist.entry_description(0), Some("SetPixel".to_string())); assert_eq!(hist.entry_description(99), None); diff --git a/hcie-iced-app/Makefile b/hcie-iced-app/Makefile new file mode 100644 index 0000000..32b01b1 --- /dev/null +++ b/hcie-iced-app/Makefile @@ -0,0 +1,21 @@ +# HCIE Iced build commands. All targets increment the shared build ID exactly once. + +.PHONY: dev build check test help + +dev: + ../scripts/cargo-with-build-id.sh run -p hcie-iced-gui + +build: + ../scripts/cargo-with-build-id.sh build --release -p hcie-iced-gui + +check: + ../scripts/cargo-with-build-id.sh check -p hcie-iced-gui + +test: + ../scripts/cargo-with-build-id.sh test -p hcie-iced-gui --tests + +help: + @echo " make dev — run Iced with synchronized build version" + @echo " make build — build release Iced binary" + @echo " make check — check Iced" + @echo " make test — test Iced" diff --git a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml index ff5c5be..d90154e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml +++ b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hcie-iced-gui" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [lib] name = "hcie_iced_gui" @@ -16,6 +16,7 @@ name = "hcie-iced" path = "src/main.rs" [dependencies] +hcie-build-info = { workspace = true } hcie-engine-api = { path = "../../../hcie-engine-api" } iced-panel-adapter = { path = "../iced-panel-adapter" } iced = { workspace = true } 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 6429147..1a65914 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -100,7 +100,11 @@ fn load_colors() -> PersistedColors { .unwrap_or_default(), Err(e) if e.kind() == std::io::ErrorKind::NotFound => PersistedColors::default(), Err(e) => { - log::warn!("Failed to read persisted colors at {}: {}", path.display(), e); + log::warn!( + "Failed to read persisted colors at {}: {}", + path.display(), + e + ); PersistedColors::default() } } @@ -171,8 +175,8 @@ pub struct HcieIcedApp { pub active_dialog: ActiveDialog, /// Index of the active tool slot in the sidebar (for sub-tools). pub active_tool_slot: usize, - /// Whether the sub-tools popup is open for the active slot. - pub sub_tools_open: bool, + /// Slot whose expanded sub-tool chooser remains open until toggled again. + pub open_subtool_slot: Option, /// Per-slot last-used sub-tool index (egui's `slot_last_used`). The sidebar /// shows the last-used tool's icon/label for each slot rather than always /// the primary tool. @@ -323,11 +327,18 @@ pub struct HcieIcedApp { pub show_dock_profile_dialog: bool, /// Current UI language for internationalization. pub _language: i18n::Language, + /// Active Brushes & Tips category filter retained while the panel redraws. + pub brush_category: crate::panels::brushes::BrushCategory, /// 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 /// persisted to disk (egui persists these under the `hcie_colors` key). pub colors_dirty: bool, + /// True while the user is dragging on the color wheel; shape sync and + /// composite refresh are deferred until the drag ends to avoid per-move lag. + pub color_dragging: bool, + /// Final color sampled during the active wheel drag; committed to Recent on mouse-up. + pub pending_drag_color: Option<[u8; 4]>, /// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration). pub vector_shapes_snapshot: Option, /// Cached `(kind, svg)` pairs from the engine's shape catalog for the active document. @@ -567,6 +578,8 @@ pub struct ToolState { pub last_update_instant: std::time::Instant, /// Imported brush presets from ABR/KPP files. pub imported_brushes: Vec, + /// Complete preset currently supplying non-style brush-tip parameters. + pub active_brush_preset: Option, /// True if currently holding shift to resize brush pub is_resizing_brush: bool, /// Starting brush size before resize drag @@ -596,6 +609,7 @@ impl Default for ToolState { last_composite_refresh: std::time::Instant::now(), last_update_instant: std::time::Instant::now(), imported_brushes: Vec::new(), + active_brush_preset: None, is_resizing_brush: false, brush_resize_start_size: 20.0, brush_resize_start_pos: None, @@ -621,6 +635,10 @@ pub enum Message { // ── Color ─────────────────────────────────────────── FgColorChanged([u8; 4]), BgColorChanged([u8; 4]), + /// Signals the start of a color-picker drag with the initial color. + ColorDragStarted([u8; 4]), + /// Signals the end of a color-picker drag; triggers deferred shape sync. + ColorDragEnded, SwapColors, ResetColors, ColorTabChanged(usize), @@ -719,6 +737,8 @@ pub enum Message { // ── Brushes ───────────────────────────────────────── BrushStyleSelected(BrushStyle), + BrushPresetSelected(hcie_engine_api::BrushPreset), + BrushCategorySelected(crate::panels::brushes::BrushCategory), BrushSizeChanged(f32), BrushSizeRelative(f32), BrushOpacityChanged(f32), @@ -1258,6 +1278,79 @@ fn build_brush_tip(state: &ToolState) -> BrushTip { } } +/// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`. +/// +/// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with +/// all shared rendering fields copied and protocol-only time dynamics left at defaults. +/// **Side Effects / Dependencies:** None. +fn protocol_tip_from_preset(source: &hcie_engine_api::brush::BrushTip) -> BrushTip { + let mut tip = BrushTip::default(); + tip.style = protocol_style_from_preset(source.style); + tip.size = source.size; + tip.opacity = source.opacity; + tip.hardness = source.hardness; + tip.spacing = source.spacing; + tip.flow = source.flow; + tip.jitter_amount = source.jitter_amount; + tip.scatter_amount = source.scatter_amount; + tip.angle = source.angle; + tip.roundness = source.roundness; + tip.spray_particle_size = source.spray_particle_size; + tip.spray_density = source.spray_density; + tip.bitmap_pixels = source.bitmap_pixels.clone(); + tip.bitmap_w = source.bitmap_w; + tip.bitmap_h = source.bitmap_h; + tip.color_variant = source.color_variant; + tip.variant_amount = source.variant_amount; + tip.density = source.density; + tip.drawing_angle = source.drawing_angle; + tip.rotation_random = source.rotation_random; + tip +} + +/// Maps the brush-engine preset enum to the protocol enum used by GUI tool state. +/// +/// **Arguments:** `style` is a serialized preset style. **Returns:** Its protocol equivalent. +/// **Side Effects / Dependencies:** None. +fn protocol_style_from_preset(style: hcie_engine_api::brush::BrushStyle) -> BrushStyle { + use hcie_engine_api::brush::BrushStyle as PresetStyle; + match style { + PresetStyle::Round => BrushStyle::Round, + PresetStyle::Square => BrushStyle::Square, + PresetStyle::HardRound => BrushStyle::HardRound, + PresetStyle::SoftRound => BrushStyle::SoftRound, + PresetStyle::Star => BrushStyle::Star, + PresetStyle::Noise => BrushStyle::Noise, + PresetStyle::Texture => BrushStyle::Texture, + PresetStyle::Spray => BrushStyle::Spray, + PresetStyle::Pencil => BrushStyle::Pencil, + PresetStyle::Pen => BrushStyle::Pen, + PresetStyle::Calligraphy => BrushStyle::Calligraphy, + PresetStyle::Oil => BrushStyle::Oil, + PresetStyle::Charcoal => BrushStyle::Charcoal, + PresetStyle::Leaf => BrushStyle::Leaf, + PresetStyle::Rock => BrushStyle::Rock, + PresetStyle::Meadow => BrushStyle::Meadow, + PresetStyle::Wood => BrushStyle::Wood, + PresetStyle::Watercolor => BrushStyle::Watercolor, + PresetStyle::Marker => BrushStyle::Marker, + PresetStyle::Sketch => BrushStyle::Sketch, + PresetStyle::Hatch => BrushStyle::Hatch, + PresetStyle::Glow => BrushStyle::Glow, + PresetStyle::Airbrush => BrushStyle::Airbrush, + PresetStyle::Crayon => BrushStyle::Crayon, + PresetStyle::WetPaint => BrushStyle::WetPaint, + PresetStyle::InkPen => BrushStyle::InkPen, + PresetStyle::Clouds => BrushStyle::Clouds, + PresetStyle::Dirt => BrushStyle::Dirt, + PresetStyle::Tree => BrushStyle::Tree, + PresetStyle::Bristle => BrushStyle::Bristle, + PresetStyle::Mixer => BrushStyle::Mixer, + PresetStyle::Blender => BrushStyle::Blender, + PresetStyle::Bitmap => BrushStyle::Bitmap, + } +} + /// Convert a display label back to a PaneType for dock profile loading. fn pane_type_from_label(label: &str) -> Option { use crate::dock::state::PaneType; @@ -1605,9 +1698,9 @@ impl HcieIcedApp { active_dialog: ActiveDialog::None, active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush) .unwrap_or(0), - sub_tools_open: false, + open_subtool_slot: None, sidebar_expanded: settings.panel_layout.sidebar_expanded, - slot_last_used: vec![0; 20], + slot_last_used: vec![0; 21], _dialog_input: String::new(), _dialog_input2: String::new(), _show_performance: false, @@ -1692,7 +1785,10 @@ impl HcieIcedApp { dock_profile_rename_buf: String::new(), show_dock_profile_dialog: false, _language: i18n::Language::default(), + brush_category: crate::panels::brushes::BrushCategory::All, colors_dirty: false, + color_dragging: false, + pending_drag_color: None, vector_shapes_snapshot: None, cached_custom_shapes: Vec::new(), vector_drag_last: None, @@ -2109,10 +2205,19 @@ impl HcieIcedApp { } /// Add a color to the recent colors list (most recent first, max 40). + /// + /// Immediately persists to disk so committed colors survive a crash or non-graceful + /// shutdown. Color-wheel movement never calls this method; the final sampled color is + /// committed once by `ColorDragEnded`. fn add_recent_color(&mut self, color: [u8; 4]) { self.recent_colors.retain(|c| *c != color); self.recent_colors.insert(0, color); self.recent_colors.truncate(40); + // Write immediately unless we are in the middle of a colour-wheel + // drag (where callers are responsible for the final flush). + if !self.color_dragging { + save_colors(self.fg_color, self.bg_color, &self.recent_colors); + } } /// Refresh the composite buffer using incremental dirty-region compositing. @@ -2287,7 +2392,17 @@ impl HcieIcedApp { /// Called before starting a stroke to ensure the engine uses /// the current brush size, opacity, hardness, and style. fn apply_brush_params(&mut self) { - let mut tip = build_brush_tip(&self.tool_state); + let mut tip = self + .tool_state + .active_brush_preset + .as_ref() + .map(|preset| protocol_tip_from_preset(&preset.tip)) + .unwrap_or_else(|| build_brush_tip(&self.tool_state)); + tip.style = self.tool_state.brush_style; + tip.size = self.tool_state.brush_size; + tip.opacity = self.tool_state.brush_opacity; + tip.hardness = self.tool_state.brush_hardness; + tip.spacing = self.tool_state.brush_spacing; match self.tool_state.active_tool { Tool::Pen => { tip.style = BrushStyle::Round; @@ -2354,7 +2469,6 @@ impl HcieIcedApp { Message::ToolSelected(tool) => { log::info!("Tool selected: {:?}", tool); self.tool_state.active_tool = tool; - self.sub_tools_open = false; if tool == Tool::Brush { self.dock.reopen_pane(crate::dock::state::PaneType::Brushes); } @@ -2386,6 +2500,7 @@ impl HcieIcedApp { } Message::ToolSlotClicked(slot_idx, primary_tool) => { + let same_slot = self.active_tool_slot == slot_idx; self.active_tool_slot = slot_idx; let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used); let selected_tool = if crate::sidebar::tool_slots().get(slot_idx).is_some() { @@ -2395,18 +2510,30 @@ impl HcieIcedApp { }; self.tool_state.active_tool = selected_tool; if selected_tool == Tool::Brush { - self.dock - .reopen_pane(crate::dock::state::PaneType::Brushes); + self.dock.reopen_pane(crate::dock::state::PaneType::Brushes); + } + let supports_popup = crate::sidebar::tool_slots() + .get(slot_idx) + .is_some_and(|slot| slot.tools.len() > 1); + if same_slot && supports_popup { + self.open_subtool_slot = if self.open_subtool_slot == Some(slot_idx) { + None + } else { + Some(slot_idx) + }; + } + if matches!(selected_tool, Tool::CustomShape(_)) { + self.dock + .reopen_pane(crate::dock::state::PaneType::CustomShapes); } - self.sub_tools_open = false; } Message::SubtoolsToggle(slot_idx) => { let supports_popup = crate::sidebar::tool_slots() .get(slot_idx) .is_some_and(|slot| slot.tools.len() > 1); if supports_popup { - let opening = !(self.sub_tools_open && self.active_tool_slot == slot_idx); - self.sub_tools_open = opening; + let opening = self.open_subtool_slot != Some(slot_idx); + self.open_subtool_slot = opening.then_some(slot_idx); self.active_tool_slot = slot_idx; if opening && !self.sidebar_expanded { self.sidebar_expanded = true; @@ -2414,13 +2541,14 @@ impl HcieIcedApp { self.settings.panel_layout.sidebar_width = 72.0; let _ = self.settings.save(); } - // If the user is opening the tool slot popup, also reveal the - // Brushes panel when the slot's current tool is the brush. if opening { let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used); if tool == Tool::Brush { + self.dock.reopen_pane(crate::dock::state::PaneType::Brushes); + } + if matches!(tool, Tool::CustomShape(_)) { self.dock - .reopen_pane(crate::dock::state::PaneType::Brushes); + .reopen_pane(crate::dock::state::PaneType::CustomShapes); } } } @@ -2429,25 +2557,45 @@ impl HcieIcedApp { if self.dock.active_auto_hide.take().is_some() { return Task::none(); } - if self.sub_tools_open { - self.sub_tools_open = false; - } else { - self.active_menu = None; - } + self.active_menu = None; } Message::FgColorChanged(color) => { self.fg_color = color; self.active_document_mut().engine.set_color(color); - self.add_recent_color(color); - self.colors_dirty = true; - crate::shape_sync::sync_shape_from_tool(self); - self.refresh_composite_if_needed(); + if self.color_dragging { + self.pending_drag_color = Some(color); + } else { + self.add_recent_color(color); + self.colors_dirty = true; + crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); + } } Message::BgColorChanged(color) => { self.bg_color = color; + self.add_recent_color(color); self.colors_dirty = true; + if !self.color_dragging { + crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); + } + } + + Message::ColorDragStarted(initial_color) => { + self.color_dragging = true; + self.pending_drag_color = Some(initial_color); + self.fg_color = initial_color; + self.active_document_mut().engine.set_color(initial_color); + } + + Message::ColorDragEnded => { + self.color_dragging = false; + if let Some(final_color) = self.pending_drag_color.take() { + self.add_recent_color(final_color); + } + self.colors_dirty = false; crate::shape_sync::sync_shape_from_tool(self); self.refresh_composite_if_needed(); } @@ -2674,6 +2822,8 @@ impl HcieIcedApp { ]; self.fg_color = color; self.documents[self.active_doc].engine.set_color(color); + self.add_recent_color(color); + self.colors_dirty = true; } } } @@ -2940,6 +3090,8 @@ impl HcieIcedApp { ]; self.fg_color = color; self.documents[self.active_doc].engine.set_color(color); + self.add_recent_color(color); + self.colors_dirty = true; } } } else if self.tool_state.is_resizing_brush { @@ -4479,6 +4631,21 @@ impl HcieIcedApp { Message::BrushStyleSelected(style) => { log::info!("Brush style selected: {:?}", style); self.tool_state.brush_style = style; + self.tool_state.active_brush_preset = None; + } + Message::BrushPresetSelected(preset) => { + log::info!("Brush preset selected: {}", preset.name); + self.tool_state.brush_style = protocol_style_from_preset(preset.style); + self.tool_state.brush_size = preset.tip.size; + self.tool_state.brush_opacity = preset.tip.opacity; + self.tool_state.brush_hardness = preset.tip.hardness; + self.tool_state.brush_spacing = preset.tip.spacing; + self.tool_state.active_brush_preset = Some(preset); + self.settings.update_from_tool_state(&self.tool_state); + let _ = self.settings.save(); + } + Message::BrushCategorySelected(category) => { + self.brush_category = category; } Message::BrushSizeChanged(size) => { self.tool_state.brush_size = size; @@ -5576,22 +5743,22 @@ impl HcieIcedApp { // Use cached catalog snapshot if available; otherwise read once from the // active document's shape catalog. `cached_custom_shapes` is populated/updated // by `RefreshCustomShapes` so drawing remains consistent with the panel. - let custom_shapes: Vec<(String, String)> = if self.cached_custom_shapes.is_empty() - { - self.documents - .get(self.active_doc) - .map(|d| { - d.engine - .shape_catalog - .all() - .iter() - .map(|e| (e.kind.clone(), e.svg.clone())) - .collect() - }) - .unwrap_or_default() - } else { - self.cached_custom_shapes.clone() - }; + let custom_shapes: Vec<(String, String)> = + if self.cached_custom_shapes.is_empty() { + self.documents + .get(self.active_doc) + .map(|d| { + d.engine + .shape_catalog + .all() + .iter() + .map(|e| (e.kind.clone(), e.svg.clone())) + .collect() + }) + .unwrap_or_default() + } else { + self.cached_custom_shapes.clone() + }; let engine = &mut self.documents[self.active_doc].engine; let color = self.fg_color; @@ -6298,9 +6465,11 @@ impl HcieIcedApp { .active_vector_shapes() .map_or(0, |s| s.len()); if idx + 1 < count { - self.documents[self.active_doc] - .engine - .reorder_vector_shape(layer_id, idx, idx + 1); + self.documents[self.active_doc].engine.reorder_vector_shape( + layer_id, + idx, + idx + 1, + ); self.documents[self.active_doc].selected_vector_shape = Some(idx + 1); self.documents[self.active_doc].modified = true; self.documents[self.active_doc] @@ -6316,9 +6485,11 @@ impl HcieIcedApp { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { if idx > 0 { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc] - .engine - .reorder_vector_shape(layer_id, idx, idx - 1); + self.documents[self.active_doc].engine.reorder_vector_shape( + layer_id, + idx, + idx - 1, + ); self.documents[self.active_doc].selected_vector_shape = Some(idx - 1); self.documents[self.active_doc].modified = true; self.documents[self.active_doc] @@ -6601,11 +6772,13 @@ impl HcieIcedApp { .engine .set_vector_shape_fill_color(layer_id, idx, c); self.documents[self.active_doc].modified = true; - crate::shape_sync::sync_tool_from_shape(self); - self.documents[self.active_doc] - .engine - .mark_composite_dirty(); - self.refresh_composite_if_needed(); + if !self.color_dragging { + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); + self.refresh_composite_if_needed(); + } } } Message::GeometryStrokeColorChanged(c) => { @@ -6615,11 +6788,13 @@ impl HcieIcedApp { .engine .set_vector_shape_color(layer_id, idx, c); self.documents[self.active_doc].modified = true; - crate::shape_sync::sync_tool_from_shape(self); - self.documents[self.active_doc] - .engine - .mark_composite_dirty(); - self.refresh_composite_if_needed(); + if !self.color_dragging { + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); + self.refresh_composite_if_needed(); + } } } Message::GeometryOpacityChanged(v) => { @@ -7962,7 +8137,6 @@ impl HcieIcedApp { // ── Menu ───────────────────────────────────── Message::MenuOpen(idx) => { - self.sub_tools_open = false; if self.active_menu == Some(idx) { self.active_menu = None; } else { @@ -8929,11 +9103,8 @@ impl HcieIcedApp { } // Mirrors egui's Escape handling. Priority: viewer > crop > // transform > vector selection > selection > dialog > viewer fallback. - match overlay_escape_target(self.sub_tools_open, self.active_menu.is_some()) { - Some(OverlayEscapeTarget::Subtools) => { - self.sub_tools_open = false; - return Task::none(); - } + match overlay_escape_target(false, self.active_menu.is_some()) { + Some(OverlayEscapeTarget::Subtools) => unreachable!(), Some(OverlayEscapeTarget::Menu) => { self.active_menu = None; return Task::none(); @@ -8944,7 +9115,7 @@ impl HcieIcedApp { return self.update(Message::ViewerToggle); } else if matches!(self.active_dialog, ActiveDialog::SvgEditor) { return self.update(Message::SvgEditorCancel); - } else if self.sub_tools_open || self.active_menu.is_some() { + } else if self.active_menu.is_some() { return Task::none(); } else if self.documents[self.active_doc].crop_state.active { return self.update(Message::CropCancel); @@ -9059,8 +9230,7 @@ impl HcieIcedApp { &self.fg_color, &self.bg_color, colors, - self.active_tool_slot, - self.sub_tools_open, + self.open_subtool_slot, &self.slot_last_used, self.sidebar_expanded, ); @@ -9226,6 +9396,7 @@ impl HcieIcedApp { self.theme_state.colors(), Some(&self.dock), self.settings.window.width, + self.settings.window.height, ) { stack = stack.push(menu_overlay); } 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 afea45a..de3cb8e 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 @@ -10,11 +10,10 @@ use crate::app::Message; use crate::theme::ThemeColors; +use crate::widgets::plain_slider::plain_slider; use iced::mouse; use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke}; -use iced::widget::{ - button, column, container, horizontal_rule, row, scrollable, slider, text, text_input, -}; +use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input}; use iced::{Element, Length, Padding, Point, Rectangle, Size}; /// Identifies the application value edited by the shared color selector. @@ -274,28 +273,31 @@ pub fn popup_view( }; let body: Element<'static, Message> = match tab { - 0 => rgb_popup_section(color), + 0 => rgb_popup_section(color, colors), 1 => Canvas::new(ColorWheel { color, + bg_color: [0, 0, 0, 255], output: WheelOutput::Popup, }) .width(Length::Fixed(220.0)) .height(Length::Fixed(220.0)) .into(), - _ => hsl_popup_section(color, h, s, l), + _ => hsl_popup_section(color, h, s, l, colors), }; let alpha = color[3]; - let alpha_row = row![ - text("A").size(10).width(16), - slider(0..=255u8, alpha, move |value| { - Message::PopupColorChanged([color[0], color[1], color[2], value]) - }) - .width(Length::Fill), - text(format!("{alpha}")).size(10).width(28), - ] - .spacing(5) - .align_y(iced::Alignment::Center); + let alpha_row = plain_slider( + "Alpha", + alpha as f32, + 0.0..=255.0, + 1.0, + "", + 0, + colors, + move |value| { + Message::PopupColorChanged([color[0], color[1], color[2], value.round() as u8]) + }, + ); let popup = column![ row![ @@ -356,32 +358,23 @@ pub fn popup_view( } /// Builds RGB sliders and directly editable byte values. -fn rgb_popup_section(color: [u8; 4]) -> Element<'static, Message> { +fn rgb_popup_section(color: [u8; 4], colors: ThemeColors) -> Element<'static, Message> { let channel = |label, index: usize| { let slider_color = color; - let input_color = color; - row![ - text(label).size(10).width(16), - slider(0..=255u8, color[index], move |value| { + plain_slider( + label, + color[index] as f32, + 0.0..=255.0, + 1.0, + "", + 0, + colors, + move |value| { let mut next = slider_color; - next[index] = value; + next[index] = value.round() as u8; Message::PopupColorChanged(next) - }) - .width(Length::Fill), - text_input("0", &color[index].to_string()) - .on_input(move |value| match value.parse::() { - Ok(value) => { - let mut next = input_color; - next[index] = value; - Message::PopupColorChanged(next) - } - Err(_) => Message::NoOp, - }) - .size(10) - .width(42), - ] - .spacing(5) - .align_y(iced::Alignment::Center) + }, + ) }; column![channel("R", 0), channel("G", 1), channel("B", 2)] .spacing(5) @@ -389,49 +382,34 @@ fn rgb_popup_section(color: [u8; 4]) -> Element<'static, Message> { } /// Builds HSL sliders and numeric readouts without duplicating conversion logic. -fn hsl_popup_section(color: [u8; 4], h: f32, s: f32, l: f32) -> Element<'static, Message> { +fn hsl_popup_section( + color: [u8; 4], + h: f32, + s: f32, + l: f32, + colors: ThemeColors, +) -> Element<'static, Message> { let alpha = color[3]; - let hue = slider(0.0..=360.0, h, move |value| { + let hue = plain_slider("H", h, 0.0..=360.0, 1.0, "°", 0, colors, move |value| { let (r, g, b) = hsl_to_rgb(value, s, l); Message::PopupColorChanged([r, g, b, alpha]) }); - let saturation = slider(0.0..=100.0, s * 100.0, move |value| { - let (r, g, b) = hsl_to_rgb(h, value / 100.0, l); + let saturation = plain_slider("S", s, 0.0..=1.0, 0.01, "%", 0, colors, move |value| { + let (r, g, b) = hsl_to_rgb(h, value, l); Message::PopupColorChanged([r, g, b, alpha]) }); - let lightness = slider(0.0..=100.0, l * 100.0, move |value| { - let (r, g, b) = hsl_to_rgb(h, s, value / 100.0); + let lightness = plain_slider("L", l, 0.0..=1.0, 0.01, "%", 0, colors, move |value| { + let (r, g, b) = hsl_to_rgb(h, s, value); Message::PopupColorChanged([r, g, b, alpha]) }); - column![ - popup_slider_row("H", hue, format!("{h:.0}")), - popup_slider_row("S", saturation, format!("{:.0}", s * 100.0)), - popup_slider_row("L", lightness, format!("{:.0}", l * 100.0)), - ] - .spacing(5) - .into() -} - -/// Aligns one popup slider with a compact numeric readout. -fn popup_slider_row<'a>( - label: &'static str, - control: iced::widget::Slider<'a, f32, Message>, - value: String, -) -> Element<'a, Message> { - row![ - text(label).size(10).width(16), - control.width(Length::Fill), - text(value).size(10).width(34) - ] - .spacing(5) - .align_y(iced::Alignment::Center) - .into() + column![hue, saturation, lightness].spacing(5).into() } /// Interactive hue ring and saturation/lightness square. #[derive(Debug, Clone, Copy)] struct ColorWheel { color: [u8; 4], + bg_color: [u8; 4], output: WheelOutput, } @@ -564,6 +542,7 @@ impl canvas::Program for ColorWheel { square_origin.x + s * square_size, square_origin.y + (1.0 - l) * square_size, ); + // Primary indicator (foreground / popup color) — white fill, black outline. for point in [hue_point, sl_point] { frame.fill(&Path::circle(point, 5.0), iced::Color::WHITE); frame.stroke( @@ -573,6 +552,29 @@ impl canvas::Program for ColorWheel { .with_width(1.5), ); } + // Secondary indicator (background color) — dark fill, white outline. + let (bh, bs, bl) = rgb_to_hsl(self.bg_color[0], self.bg_color[1], self.bg_color[2]); + let bg_hue_angle = (bh - 90.0).to_radians(); + let bg_hue_point = Point::new( + center.x + bg_hue_angle.cos() * (outer + inner) * 0.5, + center.y + bg_hue_angle.sin() * (outer + inner) * 0.5, + ); + let bg_sl_point = Point::new( + square_origin.x + bs * square_size, + square_origin.y + (1.0 - bl) * square_size, + ); + for point in [bg_hue_point, bg_sl_point] { + frame.fill( + &Path::circle(point, 4.0), + iced::Color::from_rgb(0.2, 0.2, 0.2), + ); + frame.stroke( + &Path::circle(point, 4.0), + Stroke::default() + .with_color(iced::Color::WHITE) + .with_width(1.5), + ); + } vec![frame.into_geometry()] } @@ -588,7 +590,10 @@ impl canvas::Program for ColorWheel { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { state.dragging = true; - return (canvas::event::Status::Captured, Some(self.message(color))); + return ( + canvas::event::Status::Captured, + Some(Message::ColorDragStarted(color)), + ); } } canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { @@ -605,7 +610,13 @@ impl canvas::Program for ColorWheel { } } canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { - state.dragging = false; + if state.dragging { + state.dragging = false; + return ( + canvas::event::Status::Captured, + Some(Message::ColorDragEnded), + ); + } } _ => {} } @@ -632,6 +643,7 @@ pub fn view<'a>( bg_color: &'a [u8; 4], recent_colors: &'a [[u8; 4]], color_tab: usize, + colors: ThemeColors, ) -> Element<'a, Message> { let r = fg_color[0]; let g = fg_color[1]; @@ -714,19 +726,24 @@ pub fn view<'a>( // Allows transparency editing, which egui supports in the layer-style panel // but the iced picker previously stripped entirely. Slider 0..255. let a_val = fg_color[3]; - let alpha_slider = slider(0..=255u8, a_val, move |v| { - Message::FgColorChanged([fg_color[0], fg_color[1], fg_color[2], v]) - }) - .width(Length::Fill); - let alpha_row = row![ - text("A").size(10).width(Length::Fixed(12.0)), - alpha_slider, - text(format!("{:.0}%", a_val as f32 / 255.0 * 100.0)) - .size(9) - .width(Length::Fixed(34.0)), - ] - .spacing(4) - .align_y(iced::Alignment::Center); + let alpha_rgb = [fg_color[0], fg_color[1], fg_color[2]]; + let alpha_row = plain_slider( + "Alpha", + a_val as f32 / 255.0, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + move |value| { + Message::FgColorChanged([ + alpha_rgb[0], + alpha_rgb[1], + alpha_rgb[2], + (value * 255.0).round() as u8, + ]) + }, + ); // ── 3. Tab bar ── let tab_w = 30; @@ -758,14 +775,14 @@ pub fn view<'a>( // ── 4. Tab content ── let tab_content: Element<'_, Message> = match color_tab { - 0 => build_wheel_view(h, s, l, fg_color), - 1 => build_slider_view(h, s, l, fg_color[3]), + 0 => build_wheel_view(h, s, l, fg_color, bg_color), + 1 => build_slider_view(h, s, l, fg_color[3], colors), _ => build_grid_view(fg_color), }; // ── 5. Recent colors ── let mut recent_items: Vec> = Vec::new(); - for &color in recent_colors.iter().take(20) { + for &color in recent_colors.iter().take(16) { let c = color; let is_selected = c == *fg_color; let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114; @@ -775,64 +792,82 @@ pub fn view<'a>( iced::Color::WHITE }; - let dot = text(if is_selected { "\u{2022}" } else { "" }) - .size(10) - .style(move |_theme| iced::widget::text::Style { - color: Some(dot_color), - }); + let bg = iced::Color::from_rgba( + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + c[3] as f32 / 255.0, + ); - // Wrap the tiny swatch in a transparent padded container so the - // `mouse_area` always has a non-empty hit target, including for right-clicks. - let swatch = container(dot) - .width(13) - .height(13) - .center_x(Length::Fill) - .center_y(Length::Fill) - .padding(2) + let swatch = container(iced::widget::Space::with_width(18).height(18)) + .width(18) + .height(18) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba( - c[0] as f32 / 255.0, - c[1] as f32 / 255.0, - c[2] as f32 / 255.0, - c[3] as f32 / 255.0, - ))), + background: Some(iced::Background::Color(bg)), border: if is_selected { iced::Border::default() .color(dot_color) - .width(1.0) + .width(1.5) .rounded(2) } else { - iced::Border::default().rounded(2) + iced::Border::default() + .color(iced::Color::from_rgba(0.5, 0.5, 0.5, 0.7)) + .width(1.0) + .rounded(2) }, ..Default::default() }); - let clickable = iced::widget::mouse_area(swatch) + + let clickable: Element<'_, Message> = iced::widget::mouse_area(swatch) .on_press(Message::FgColorChanged(c)) .on_right_press(Message::BgColorChanged(c)) - .interaction(iced::mouse::Interaction::Pointer); - recent_items.push(clickable.into()); + .interaction(iced::mouse::Interaction::Pointer) + .into(); + recent_items.push(clickable); } + let recent_count = recent_items.len(); let recent_row: Element<'_, Message> = if recent_items.is_empty() { - text("").into() + text("No recent colors").size(9).into() } else { - row(recent_items).spacing(2).into() + // Eight swatches fit even at the panel's minimum supported width. + // Element does not implement Clone, so we drain the vec into fixed-size chunks. + let mut rows: Vec> = Vec::new(); + let mut drain = recent_items; + while !drain.is_empty() { + let chunk_size = drain.len().min(8); + // split_off + drain approach: take the first chunk_size elements + let row_items: Vec> = drain.drain(..chunk_size).collect(); + let row_el: Element<'_, Message> = iced::widget::Row::with_children(row_items) + .spacing(3) + .into(); + rows.push(row_el); + } + iced::widget::Column::with_children(rows).spacing(2).into() }; + let recent_rows = recent_count.max(1).div_ceil(8); + let recent_section = container(column![text("Recent").size(9), recent_row].spacing(3)) + .width(Length::Fill) + .height(Length::Fixed(15.0 + recent_rows as f32 * 21.0)); let content = column![ text("Color").size(11).font(iced::Font::MONOSPACE), swatch_row, hex_row, alpha_row, + recent_section, + horizontal_rule(1), tabs, tab_content, - horizontal_rule(1), - text("Recent").size(9), - recent_row, ] .width(Length::Fill) .height(Length::Shrink) .spacing(3) - .padding(Padding::from([4, 4])); + .padding(Padding { + top: 4.0, + right: 14.0, + bottom: 4.0, + left: 4.0, + }); scrollable(content) .width(Length::Fill) @@ -844,7 +879,13 @@ pub fn view<'a>( /// /// The wheel fills narrow pane widths while retaining intrinsic vertical height, avoiding the /// former fixed 360-pixel hue strip and allowing the outer panel scrollable to handle short panes. -fn build_wheel_view<'a>(h: f32, s: f32, l: f32, fg_color: &'a [u8; 4]) -> Element<'a, Message> { +fn build_wheel_view<'a>( + h: f32, + s: f32, + l: f32, + fg_color: &'a [u8; 4], + bg_color: &'a [u8; 4], +) -> Element<'a, Message> { let readout = text(format!( "H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", h, @@ -856,112 +897,146 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, fg_color: &'a [u8; 4]) -> Elemen let wheel = Canvas::new(ColorWheel { color: *fg_color, + bg_color: *bg_color, output: WheelOutput::Foreground, }) .width(Length::Fill) - .height(Length::Fixed(220.0)); + .height(Length::Fixed(160.0)); column![wheel, readout].spacing(3).into() } /// Build the HSL sliders view (tab H). -fn build_slider_view<'a>(h: f32, s: f32, l: f32, alpha: u8) -> Element<'a, Message> { - let h_slider = slider(0.0..=360.0, h, move |v: f32| { +fn build_slider_view<'a>( + h: f32, + s: f32, + l: f32, + alpha: u8, + colors: ThemeColors, +) -> Element<'a, Message> { + let h_slider = plain_slider("H", h, 0.0..=360.0, 1.0, "°", 0, colors, move |v| { let (r, g, b) = hsl_to_rgb(v, s, l); Message::FgColorChanged([r, g, b, alpha]) - }) - .step(1.0); - let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| { - let (r, g, b) = hsl_to_rgb(h, v / 100.0, l); + }); + let s_slider = plain_slider("S", s, 0.0..=1.0, 0.01, "%", 0, colors, move |v| { + let (r, g, b) = hsl_to_rgb(h, v, l); Message::FgColorChanged([r, g, b, alpha]) - }) - .step(1.0); - let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| { - let (r, g, b) = hsl_to_rgb(h, s, v / 100.0); + }); + let l_slider = plain_slider("L", l, 0.0..=1.0, 0.01, "%", 0, colors, move |v| { + let (r, g, b) = hsl_to_rgb(h, s, v); Message::FgColorChanged([r, g, b, alpha]) - }) - .step(1.0); + }); - column![ - row![ - text("H").size(9), - h_slider, - text(format!("{:.0}\u{00B0}", h)).size(9) - ] - .spacing(2), - row![ - text("S").size(9), - s_slider, - text(format!("{:.0}%", s * 100.0)).size(9) - ] - .spacing(2), - row![ - text("L").size(9), - l_slider, - text(format!("{:.0}%", l * 100.0)).size(9) - ] - .spacing(2), - ] - .spacing(2) - .into() + column![h_slider, s_slider, l_slider].spacing(2).into() } -/// Build the palette grid view (tab G). +/// Build the palette grid view (tab G), matching the egui implementation. +/// +/// Renders a 10×10 grid of algorithmically-generated palette colors. Each cell +/// is a clickable `mouse_area` that sets the foreground color (left-click) or +/// background color (right-click). The currently selected color is indicated by +/// a border stroke and a center dot, matching the egui version's visual style. +/// +/// **Layout:** Dynamic cell sizing based on available width (clamped 8..18px), +/// with 2px gap between cells and 4px padding around the grid. fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> { - let mut palette_grid = column![].spacing(1); - for row_idx in 0..10 { - let mut row_widgets = row![].spacing(1); - for col_idx in 0..10 { + const GRID_ROWS: usize = 10; + const GRID_COLS: usize = 10; + let gap: f32 = 2.0; + let padding: f32 = 4.0; + // Use a fixed cell size that fits the panel width well. + let cell: f32 = 16.0; + let cell_rounding = 2.0; + + let mut grid_rows: Vec> = Vec::new(); + for row_idx in 0..GRID_ROWS { + let mut row_widgets: Vec> = Vec::new(); + for col_idx in 0..GRID_COLS { let c = palette_color(row_idx, col_idx); - let is_selected = [c[0], c[1], c[2], 255] == *fg_color; + let is_selected = c[0] == fg_color[0] && c[1] == fg_color[1] && c[2] == fg_color[2]; let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114; - let dot_color = if brightness > 128.0 { + + // Selection indicator color: black for light cells, white for dark. + let sc = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE }; - let dot = text(if is_selected { "\u{2022}" } else { "" }) - .size(10) - .style(move |_theme| iced::widget::text::Style { - color: Some(dot_color), - }); + let bg = iced::Color::from_rgb( + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + ); - // Pad the palette cell so the `mouse_area` has a non-empty hit - // target even when the inner swatch is only 14×14 pixels. - let swatch = container(dot) - .width(14) - .height(14) - .center_x(Length::Fill) - .center_y(Length::Fill) - .padding(2) + // Inner colored swatch with optional selection indicator (border + center dot). + let swatch_bg = bg; + let border_color = sc; + let swatch = container(text("")) + .width(cell) + .height(cell) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgb( - c[0] as f32 / 255.0, - c[1] as f32 / 255.0, - c[2] as f32 / 255.0, - ))), + background: Some(iced::Background::Color(swatch_bg)), border: if is_selected { iced::Border::default() - .color(dot_color) - .width(1.0) - .rounded(1) + .color(border_color) + .width(1.5) + .rounded(cell_rounding) } else { - iced::Border::default().rounded(1) + iced::Border::default().rounded(cell_rounding) }, ..Default::default() }); - let clickable = iced::widget::mouse_area(swatch) + + // Wrap in a stack to overlay the selection dot on top of the swatch. + let content: Element<'a, Message> = if is_selected { + let dot = container(text("")) + .width(4.0) + .height(4.0) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(sc)), + border: iced::Border::default().rounded(2.0), + ..Default::default() + }); + iced::widget::Stack::new() + .push(swatch) + .push(container(dot).center_x(cell).center_y(cell)) + .into() + } else { + swatch.into() + }; + + let clickable: Element<'a, Message> = iced::widget::mouse_area(content) .on_press(Message::FgColorChanged([c[0], c[1], c[2], fg_color[3]])) .on_right_press(Message::BgColorChanged([c[0], c[1], c[2], fg_color[3]])) - .interaction(iced::mouse::Interaction::Pointer); - row_widgets = row_widgets.push(clickable); + .interaction(iced::mouse::Interaction::Pointer) + .into(); + row_widgets.push(clickable); } - palette_grid = palette_grid.push(row_widgets); + let row_el = iced::widget::Row::with_children(row_widgets) + .spacing(gap) + .align_y(iced::Alignment::Center); + grid_rows.push(row_el.into()); } - column![text("Palette").size(10), palette_grid,] - .spacing(2) + let grid_col = iced::widget::Column::with_children(grid_rows) + .spacing(gap) + .align_x(iced::Alignment::Center); + + // Wrap in a bordered section frame to match the egui visual style. + let section = + container(grid_col) + .padding(padding) + .style(move |_theme| iced::widget::container::Style { + border: iced::Border::default() + .color(iced::Color::from_rgb(0.35, 0.35, 0.35)) + .width(1) + .rounded(4), + ..Default::default() + }); + + column![text("Palette").size(10), section,] + .spacing(4) .into() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/adjustments.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/adjustments.rs index cd829ee..b9e1166 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/adjustments.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/adjustments.rs @@ -19,6 +19,7 @@ pub fn brightness_contrast_view( 1.0, "", 0, + colors, |v| Message::AdjBrightness(v), ); @@ -29,6 +30,7 @@ pub fn brightness_contrast_view( 1.0, "", 0, + colors, |v| Message::AdjContrast(v), ); @@ -59,17 +61,31 @@ pub fn hsl_view( lightness: f32, colors: ThemeColors, ) -> Element<'static, Message> { - let hue_slider = plain_slider("Hue", hue, -180.0..=180.0, 1.0, "°", 0, |v| { + let hue_slider = plain_slider("Hue", hue, -180.0..=180.0, 1.0, "°", 0, colors, |v| { Message::AdjHue(v) }); - let sat_slider = plain_slider("Saturation", saturation, -100.0..=100.0, 1.0, "", 0, |v| { - Message::AdjSaturation(v) - }); + let sat_slider = plain_slider( + "Saturation", + saturation, + -100.0..=100.0, + 1.0, + "", + 0, + colors, + |v| Message::AdjSaturation(v), + ); - let light_slider = plain_slider("Lightness", lightness, -100.0..=100.0, 1.0, "", 0, |v| { - Message::AdjLightness(v) - }); + let light_slider = plain_slider( + "Lightness", + lightness, + -100.0..=100.0, + 1.0, + "", + 0, + colors, + |v| Message::AdjLightness(v), + ); let apply_btn = super::primary_btn("Apply", Message::AdjApply, colors); let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs index 18de50f..494543f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs @@ -57,6 +57,7 @@ pub fn selection_op_view( step, "", 0, + colors, |v| Message::SelectionOpValue(v), ); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs index 90647c7..9042651 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs @@ -10,8 +10,8 @@ pub mod adjustments; pub mod canvas_size; pub mod confirm; pub mod dock_profile; -pub mod image_size; pub mod expand_canvas; +pub mod image_size; pub mod new_image; pub mod save_error; @@ -29,30 +29,94 @@ pub fn modal<'a>( content: impl Into>, colors: ThemeColors, ) -> Element<'a, Message> { - let card = container(content).style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_panel)), - text_color: Some(colors.text_primary), - border: iced::Border::default() - .rounded(3) - .color(colors.border_high) - .width(1), - shadow: iced::Shadow { - color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.45), - offset: iced::Vector::new(0.0, 4.0), - blur_radius: 18.0, - }, - ..Default::default() - }); + let card = container(content) + .padding(1) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + text_color: Some(colors.text_primary), + border: iced::Border::default() + .rounded(4) + .color(colors.border_high) + .width(2), + shadow: iced::Shadow { + color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.55), + offset: iced::Vector::new(0.0, 6.0), + blur_radius: 24.0, + }, + ..Default::default() + }); - container(card) - .center_x(Length::Fill) - .center_y(Length::Fill) + let backdrop = container(iced::widget::Space::with_width(Length::Fill).height(Length::Fill)) + .width(Length::Fill) + .height(Length::Fill) .style(|_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - 0.0, 0.0, 0.0, 0.55, + 0.0, 0.0, 0.0, 0.62, ))), ..Default::default() - }) + }); + let positioned_card = container(card) + .center_x(Length::Fill) + .center_y(Length::Fill) + .width(Length::Fill) + .height(Length::Fill); + + iced::widget::Stack::new() + .push(backdrop) + .push(positioned_card) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// Wraps fixed-size dialog content in an explicit opaque card surface. +pub fn modal_sized<'a>( + content: impl Into>, + colors: ThemeColors, + width: f32, + height: f32, +) -> Element<'a, Message> { + let mut surface_color = colors.bg_panel; + surface_color.a = 1.0; + let card_surface = container(iced::widget::Space::with_width(width).height(height)) + .width(width) + .height(height) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(surface_color)), + border: iced::Border::default() + .rounded(4) + .color(colors.border_high) + .width(2), + shadow: iced::Shadow { + color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.55), + offset: iced::Vector::new(0.0, 6.0), + blur_radius: 24.0, + }, + ..Default::default() + }); + let card = iced::widget::Stack::new() + .push(card_surface) + .push(content) + .width(width) + .height(height); + let backdrop = container(iced::widget::Space::with_width(Length::Fill).height(Length::Fill)) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.62, + ))), + ..Default::default() + }); + let positioned_card = container(card) + .center_x(Length::Fill) + .center_y(Length::Fill) + .width(Length::Fill) + .height(Length::Fill); + + iced::widget::Stack::new() + .push(backdrop) + .push(positioned_card) .width(Length::Fill) .height(Length::Fill) .into() @@ -89,16 +153,16 @@ fn darken(c: iced::Color, factor: f32) -> iced::Color { /// and `colors` supplies the theme tokens. /// **Returns:** A styled `Element` ready for the dialog button row. /// **Side Effects / Dependencies:** None. -pub fn primary_btn( - label: &str, - msg: Message, - colors: ThemeColors, -) -> Element<'static, Message> { +pub fn primary_btn(label: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> { let l = label.to_string(); let c = colors; - button(text(l).size(12).style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(iced::Color::WHITE), - })) + button( + text(l) + .size(12) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(iced::Color::WHITE), + }), + ) .on_press(msg) .padding([8, 16]) .style(move |_theme, status| { @@ -128,16 +192,16 @@ pub fn primary_btn( /// and `colors` supplies the theme tokens. /// **Returns:** A styled `Element` ready for the dialog button row. /// **Side Effects / Dependencies:** None. -pub fn secondary_btn( - label: &str, - msg: Message, - colors: ThemeColors, -) -> Element<'static, Message> { +pub fn secondary_btn(label: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> { let l = label.to_string(); let c = colors; - button(text(l).size(12).style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(c.text_primary), - })) + button( + text(l) + .size(12) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(c.text_primary), + }), + ) .on_press(msg) .padding([8, 16]) .style(move |_theme, status| { @@ -180,48 +244,53 @@ pub fn preset_btn( ) -> Element<'static, Message> { let l = label.to_string(); let c = colors; - let mut btn = button( - text(l) - .size(11) - .style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(if enabled { c.text_primary } else { c.text_disabled }), - }), - ) - .padding([5, 8]) - .width(Length::Fill) - .style(move |_theme, status| { - let (bg, border_color) = if !enabled { - (iced::Color::TRANSPARENT, iced::Color::TRANSPARENT) - } else { - match status { - iced::widget::button::Status::Hovered => (c.bg_hover, c.border_high), - iced::widget::button::Status::Pressed => (darken(c.bg_hover, 0.1), c.accent), - _ => (iced::Color::TRANSPARENT, iced::Color::TRANSPARENT), - } - }; - iced::widget::button::Style { - background: Some(iced::Background::Color(bg)), - text_color: if enabled { - c.text_primary - } else { - c.text_disabled - }, - border: Border::default() - .rounded(4) - .color(border_color) - .width(if enabled - && matches!( - status, - iced::widget::button::Status::Hovered | iced::widget::button::Status::Pressed - ) - { - 1 - } else { - 0 + let mut btn = + button( + text(l) + .size(11) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(if enabled { + c.text_primary + } else { + c.text_disabled + }), }), - ..Default::default() - } - }); + ) + .padding([5, 8]) + .width(Length::Fill) + .style(move |_theme, status| { + let (bg, border_color) = if !enabled { + (iced::Color::TRANSPARENT, iced::Color::TRANSPARENT) + } else { + match status { + iced::widget::button::Status::Hovered => (c.bg_hover, c.border_high), + iced::widget::button::Status::Pressed => (darken(c.bg_hover, 0.1), c.accent), + _ => (iced::Color::TRANSPARENT, iced::Color::TRANSPARENT), + } + }; + iced::widget::button::Style { + background: Some(iced::Background::Color(bg)), + text_color: if enabled { + c.text_primary + } else { + c.text_disabled + }, + border: Border::default().rounded(4).color(border_color).width( + if enabled + && matches!( + status, + iced::widget::button::Status::Hovered + | iced::widget::button::Status::Pressed + ) + { + 1 + } else { + 0 + }, + ), + ..Default::default() + } + }); if enabled { btn = btn.on_press(msg); } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs index 7f4c444..6a313b8 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs @@ -3,7 +3,9 @@ use crate::app::Message; use crate::theme::ThemeColors; use crate::widgets::plain_slider::plain_slider; -use iced::widget::{checkbox, column, container, horizontal_rule, row, scrollable, text, text_input}; +use iced::widget::{ + checkbox, column, container, horizontal_rule, row, scrollable, text, text_input, +}; use iced::{Element, Length}; /// A canvas preset size. @@ -75,6 +77,7 @@ pub fn view( 1.0, "px", 0, + colors, |v| Message::DialogNewImageWidth(v as u32), ); @@ -85,6 +88,7 @@ pub fn view( 1.0, "px", 0, + colors, |v| Message::DialogNewImageHeight(v as u32), ); @@ -96,7 +100,8 @@ pub fn view( let label = preset.name; let w = preset.width; let h = preset.height; - let preset_btn = super::preset_btn(label, Message::DialogNewImagePreset(w, h), colors, true); + let preset_btn = + super::preset_btn(label, Message::DialogNewImagePreset(w, h), colors, true); presets_col = presets_col.push(preset_btn); } @@ -135,7 +140,8 @@ pub fn view( ] .spacing(6) .padding(16) - .width(400); + .width(400) + .height(560); - super::modal(dialog, colors) + super::modal_sized(dialog, colors, 400.0, 560.0) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index d0089cf..a29ebb2 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -182,6 +182,8 @@ pub fn panel_body<'a>( app.tool_state.brush_size, app.tool_state.brush_opacity, app.tool_state.brush_hardness, + app.brush_category, + &app.tool_state.imported_brushes, colors, ), PaneType::Filters => crate::panels::filters::view( @@ -199,6 +201,7 @@ pub fn panel_body<'a>( &app.bg_color, &app.recent_colors, app.color_tab, + colors, ), PaneType::Properties => { let doc = &app.documents[app.active_doc]; @@ -281,13 +284,7 @@ pub fn panel_body<'a>( PaneType::ToolSettings => { let fg = app.fg_color; let draft = app.documents[app.active_doc].text_draft.as_ref(); - crate::panels::tool_settings::view( - &app.tool_state, - &app.settings, - fg, - draft, - colors, - ) + crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors) } PaneType::CustomShapes => { let selected_index = match app.tool_state.active_tool { @@ -295,10 +292,7 @@ pub fn panel_body<'a>( _ => None, }; crate::panels::custom_shapes::view( - app.documents[app.active_doc] - .engine - .shape_catalog - .all(), + app.documents[app.active_doc].engine.shape_catalog.all(), selected_index, colors, ) 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 2253981..dc6bd56 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs @@ -3,5 +3,6 @@ pub mod cli; pub mod raster; pub mod selection; +pub mod theme; pub mod vector_edit; pub mod widgets; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs index d52038a..4d4026f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs @@ -11,144 +11,201 @@ use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; +use crate::widgets::plain_slider::plain_slider; use hcie_engine_api::BrushStyle; -use iced::widget::{ - button, column, container, horizontal_rule, row, scrollable, slider, svg, text, -}; +use iced::widget::{button, column, container, horizontal_rule, row, scrollable, svg, text}; use iced::{Element, Length}; use std::sync::OnceLock; -/// A brush preset entry with category. -#[allow(dead_code)] -struct BrushPreset { +/// One built-in brush style exposed by the engine. +struct BrushStyleEntry { name: &'static str, style: BrushStyle, category: BrushCategory, } +/// User-selectable category used to filter brush styles and runtime presets. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -enum BrushCategory { +pub enum BrushCategory { All, Drawing, Painting, Effects, + Custom, + Imported, } -const BRUSH_PRESETS: &[BrushPreset] = &[ - // Drawing - BrushPreset { +const BRUSH_STYLES: &[BrushStyleEntry] = &[ + BrushStyleEntry { name: "Default", style: BrushStyle::Default, category: BrushCategory::Drawing, }, - BrushPreset { + BrushStyleEntry { + name: "Round", + style: BrushStyle::Round, + category: BrushCategory::Drawing, + }, + BrushStyleEntry { + name: "Square", + style: BrushStyle::Square, + category: BrushCategory::Drawing, + }, + BrushStyleEntry { + name: "Hard Round", + style: BrushStyle::HardRound, + category: BrushCategory::Drawing, + }, + BrushStyleEntry { + name: "Soft Round", + style: BrushStyle::SoftRound, + category: BrushCategory::Drawing, + }, + BrushStyleEntry { name: "Pencil", style: BrushStyle::Pencil, category: BrushCategory::Drawing, }, - BrushPreset { + BrushStyleEntry { name: "Pen", style: BrushStyle::Pen, category: BrushCategory::Drawing, }, - BrushPreset { + BrushStyleEntry { name: "Ink Pen", style: BrushStyle::InkPen, category: BrushCategory::Drawing, }, - BrushPreset { + BrushStyleEntry { name: "Calligraphy", style: BrushStyle::Calligraphy, category: BrushCategory::Drawing, }, - BrushPreset { - name: "Sketch", - style: BrushStyle::Sketch, - category: BrushCategory::Drawing, - }, - BrushPreset { + BrushStyleEntry { name: "Marker", style: BrushStyle::Marker, category: BrushCategory::Drawing, }, - BrushPreset { + BrushStyleEntry { + name: "Sketch", + style: BrushStyle::Sketch, + category: BrushCategory::Drawing, + }, + BrushStyleEntry { name: "Hatch", style: BrushStyle::Hatch, category: BrushCategory::Drawing, }, - // Painting - BrushPreset { + BrushStyleEntry { + name: "Star", + style: BrushStyle::Star, + category: BrushCategory::Drawing, + }, + BrushStyleEntry { name: "Oil", style: BrushStyle::Oil, category: BrushCategory::Painting, }, - BrushPreset { + BrushStyleEntry { name: "Charcoal", style: BrushStyle::Charcoal, category: BrushCategory::Painting, }, - BrushPreset { + BrushStyleEntry { + name: "Wood", + style: BrushStyle::Wood, + category: BrushCategory::Painting, + }, + BrushStyleEntry { name: "Watercolor", style: BrushStyle::Watercolor, category: BrushCategory::Painting, }, - BrushPreset { - name: "Crayon", - style: BrushStyle::Crayon, - category: BrushCategory::Painting, - }, - BrushPreset { + BrushStyleEntry { name: "Airbrush", style: BrushStyle::Airbrush, category: BrushCategory::Painting, }, - BrushPreset { - name: "Spray", - style: BrushStyle::Spray, + BrushStyleEntry { + name: "Crayon", + style: BrushStyle::Crayon, category: BrushCategory::Painting, }, - BrushPreset { - name: "Soft Round", - style: BrushStyle::SoftRound, + BrushStyleEntry { + name: "Wet Paint", + style: BrushStyle::WetPaint, category: BrushCategory::Painting, }, - BrushPreset { - name: "Hard Round", - style: BrushStyle::HardRound, + BrushStyleEntry { + name: "Bristle", + style: BrushStyle::Bristle, category: BrushCategory::Painting, }, - // Effects - BrushPreset { - name: "Star", - style: BrushStyle::Star, - category: BrushCategory::Effects, + BrushStyleEntry { + name: "Mixer", + style: BrushStyle::Mixer, + category: BrushCategory::Painting, }, - BrushPreset { + BrushStyleEntry { + name: "Blender", + style: BrushStyle::Blender, + category: BrushCategory::Painting, + }, + BrushStyleEntry { name: "Noise", style: BrushStyle::Noise, category: BrushCategory::Effects, }, - BrushPreset { + BrushStyleEntry { name: "Texture", style: BrushStyle::Texture, category: BrushCategory::Effects, }, - BrushPreset { - name: "Glow", - style: BrushStyle::Glow, + BrushStyleEntry { + name: "Spray", + style: BrushStyle::Spray, category: BrushCategory::Effects, }, - BrushPreset { + BrushStyleEntry { name: "Leaf", style: BrushStyle::Leaf, category: BrushCategory::Effects, }, - BrushPreset { + BrushStyleEntry { + name: "Rock", + style: BrushStyle::Rock, + category: BrushCategory::Effects, + }, + BrushStyleEntry { + name: "Meadow", + style: BrushStyle::Meadow, + category: BrushCategory::Effects, + }, + BrushStyleEntry { + name: "Glow", + style: BrushStyle::Glow, + category: BrushCategory::Effects, + }, + BrushStyleEntry { name: "Clouds", style: BrushStyle::Clouds, category: BrushCategory::Effects, }, + BrushStyleEntry { + name: "Dirt", + style: BrushStyle::Dirt, + category: BrushCategory::Effects, + }, + BrushStyleEntry { + name: "Tree", + style: BrushStyle::Tree, + category: BrushCategory::Effects, + }, + BrushStyleEntry { + name: "Imported", + style: BrushStyle::Bitmap, + category: BrushCategory::Imported, + }, ]; /// Cached brush preview images: (style_index, pixels). @@ -157,6 +214,64 @@ static BRUSH_PREVIEW_CACHE: OnceLock)>> = OnceLock::new(); /// Thumbnail size in pixels. const THUMB_SIZE: u32 = 64; +/// Determines whether a built-in style belongs in the selected category. +/// +/// **Arguments:** `active` is the selected filter and `style_category` is the catalog grouping. +/// **Returns:** `true` when the style should remain visible. **Side Effects:** None. +fn category_matches_style(active: BrushCategory, style_category: BrushCategory) -> bool { + match active { + BrushCategory::All => true, + BrushCategory::Drawing | BrushCategory::Painting | BrushCategory::Effects => { + active == style_category + } + BrushCategory::Custom => false, + BrushCategory::Imported => style_category == BrushCategory::Imported, + } +} + +/// Maps a runtime preset style into the protocol style rendered by this panel. +/// +/// **Arguments:** `style` comes from `hcie_brush_engine` through the public preset API. +/// **Returns:** The equivalent protocol `BrushStyle`. **Side Effects:** None. +fn preset_style(style: hcie_engine_api::brush::BrushStyle) -> BrushStyle { + use hcie_engine_api::brush::BrushStyle as PresetStyle; + match style { + PresetStyle::Round => BrushStyle::Round, + PresetStyle::Square => BrushStyle::Square, + PresetStyle::HardRound => BrushStyle::HardRound, + PresetStyle::SoftRound => BrushStyle::SoftRound, + PresetStyle::Star => BrushStyle::Star, + PresetStyle::Noise => BrushStyle::Noise, + PresetStyle::Texture => BrushStyle::Texture, + PresetStyle::Spray => BrushStyle::Spray, + PresetStyle::Pencil => BrushStyle::Pencil, + PresetStyle::Pen => BrushStyle::Pen, + PresetStyle::Calligraphy => BrushStyle::Calligraphy, + PresetStyle::Oil => BrushStyle::Oil, + PresetStyle::Charcoal => BrushStyle::Charcoal, + PresetStyle::Leaf => BrushStyle::Leaf, + PresetStyle::Rock => BrushStyle::Rock, + PresetStyle::Meadow => BrushStyle::Meadow, + PresetStyle::Wood => BrushStyle::Wood, + PresetStyle::Watercolor => BrushStyle::Watercolor, + PresetStyle::Marker => BrushStyle::Marker, + PresetStyle::Sketch => BrushStyle::Sketch, + PresetStyle::Hatch => BrushStyle::Hatch, + PresetStyle::Glow => BrushStyle::Glow, + PresetStyle::Airbrush => BrushStyle::Airbrush, + PresetStyle::Crayon => BrushStyle::Crayon, + PresetStyle::WetPaint => BrushStyle::WetPaint, + PresetStyle::InkPen => BrushStyle::InkPen, + PresetStyle::Clouds => BrushStyle::Clouds, + PresetStyle::Dirt => BrushStyle::Dirt, + PresetStyle::Tree => BrushStyle::Tree, + PresetStyle::Bristle => BrushStyle::Bristle, + PresetStyle::Mixer => BrushStyle::Mixer, + PresetStyle::Blender => BrushStyle::Blender, + PresetStyle::Bitmap => BrushStyle::Bitmap, + } +} + /// Generate a brush stroke preview as RGBA pixels. /// /// Draws a sine-wave stroke with varying thickness to show the brush characteristics. @@ -295,6 +410,8 @@ pub fn view( current_size: f32, current_opacity: f32, current_hardness: f32, + active_category: BrushCategory, + imported_presets: &[hcie_engine_api::BrushPreset], colors: ThemeColors, ) -> Element<'static, Message> { // Category tabs @@ -303,11 +420,13 @@ pub fn view( (BrushCategory::Drawing, "Drawing", "icons/pen.svg"), (BrushCategory::Painting, "Painting", "icons/brush.svg"), (BrushCategory::Effects, "Effects", "icons/glow.svg"), + (BrushCategory::Custom, "Custom", "icons/star.svg"), + (BrushCategory::Imported, "Imported", "icons/import_abr.svg"), ]; let mut tabs = row![].spacing(2); for (cat, label, icon_path) in categories { - let active = cat == BrushCategory::All; + let active = cat == active_category; let icon = svg(svg::Handle::from_path( std::path::Path::new("hcie-iced-app/assets").join(icon_path), )) @@ -320,39 +439,42 @@ pub fn view( colors.text_secondary }), }); - let btn = button(icon).padding([3, 6]).style( - move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { - iced::widget::button::Status::Hovered => iced::widget::button::Style { - background: Some(iced::Background::Color(colors.bg_hover)), - text_color: colors.text_primary, - border: iced::Border::default(), - ..Default::default() - }, - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(if active { - colors.bg_active - } else { - iced::Color::TRANSPARENT - })), - text_color: if active { - colors.accent - } else { - colors.text_secondary + let btn = button(icon) + .on_press(Message::BrushCategorySelected(cat)) + .padding([3, 6]) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_hover)), + text_color: colors.text_primary, + border: iced::Border::default(), + ..Default::default() }, - border: iced::Border::default() - .color(if active { + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(if active { + colors.bg_active + } else { + iced::Color::TRANSPARENT + })), + text_color: if active { colors.accent } else { - colors.border_low - }) - .width(if active { 1 } else { 0 }), - ..Default::default() + colors.text_secondary + }, + border: iced::Border::default() + .color(if active { + colors.accent + } else { + colors.border_low + }) + .width(if active { 1 } else { 0 }), + ..Default::default() + }, }, - }, - ); + ); tabs = tabs.push(styles::balloon_tooltip( btn, - format!("{label} (category filtering is not available yet)"), + label, iced::widget::tooltip::Position::Bottom, colors, )); @@ -362,7 +484,12 @@ pub fn view( let mut grid_rows = column![].spacing(4); let mut current_row = row![].spacing(4); - for (idx, preset) in BRUSH_PRESETS.iter().enumerate() { + let filtered_styles: Vec<&BrushStyleEntry> = BRUSH_STYLES + .iter() + .filter(|entry| category_matches_style(active_category, entry.category)) + .collect(); + + for (idx, preset) in filtered_styles.iter().enumerate() { let is_selected = preset.style == current_style; let preview = get_brush_preview(preset.style); let c = colors; @@ -373,21 +500,7 @@ pub fn view( .height(56) .center_x(48) .center_y(48) - .style(move |_theme| { - if is_selected { - iced::widget::container::Style { - background: Some(iced::Background::Color(c.bg_active)), - border: iced::Border::default().color(c.accent).width(2), - ..Default::default() - } - } else { - iced::widget::container::Style { - background: Some(iced::Background::Color(c.bg_panel)), - border: iced::Border::default().color(c.border_low).width(1), - ..Default::default() - } - } - }); + .style(move |_theme| styles::raised_card(c, is_selected)); let label = container(text(preset.name).size(9)) .width(Length::Fill) @@ -421,28 +534,111 @@ pub fn view( current_row = current_row.push(clickable); - if (idx + 1) % 2 == 0 || idx == BRUSH_PRESETS.len() - 1 { + if (idx + 1) % 2 == 0 || idx == filtered_styles.len() - 1 { grid_rows = grid_rows.push(current_row); current_row = row![].spacing(4); } } + let mut imported_rows = column![].spacing(3); + if matches!( + active_category, + BrushCategory::All | BrushCategory::Imported + ) { + for preset in imported_presets { + let style = preset_style(preset.style); + let selected = style == current_style; + let preset_message = preset.clone(); + let preview = get_brush_preview(style); + let name = preset.name.clone(); + imported_rows = imported_rows.push( + button( + row![ + container(iced::widget::image(preview).width(42).height(24)) + .width(48) + .height(28) + .center_x(Length::Fill) + .center_y(Length::Fill), + column![ + text(name).size(10), + text("Imported ABR").size(8).color(colors.text_secondary), + ] + .spacing(1), + ] + .spacing(6) + .align_y(iced::Alignment::Center), + ) + .on_press(Message::BrushPresetSelected(preset_message)) + .width(Length::Fill) + .padding(3) + .style(move |_theme, status| iced::widget::button::Style { + background: Some(iced::Background::Color(if selected { + colors.bg_active + } else if status == iced::widget::button::Status::Hovered { + colors.bg_hover + } else { + colors.bg_panel + })), + border: iced::Border::default() + .color(if selected { + colors.accent + } else { + colors.border_low + }) + .width(1) + .rounded(4), + text_color: colors.text_primary, + ..Default::default() + }), + ); + } + } + + if active_category == BrushCategory::Custom + || (active_category == BrushCategory::Imported && imported_presets.is_empty()) + { + imported_rows = imported_rows.push( + text(if active_category == BrushCategory::Custom { + "No custom presets" + } else { + "No imported presets" + }) + .size(10) + .color(colors.text_secondary), + ); + } + // Sliders - let size_slider = slider(1.0..=200.0, current_size, |v| Message::BrushSizeChanged(v)) - .step(1.0) - .width(Length::Fill); - - let opacity_slider = slider(0.0..=1.0, current_opacity, |v| { - Message::BrushOpacityChanged(v) - }) - .step(0.01) - .width(Length::Fill); - - let hardness_slider = slider(0.0..=1.0, current_hardness, |v| { - Message::BrushHardnessChanged(v) - }) - .step(0.01) - .width(Length::Fill); + let size_slider = plain_slider( + "Size", + current_size, + 1.0..=200.0, + 1.0, + "px", + 0, + colors, + Message::BrushSizeChanged, + ); + let opacity_slider = plain_slider( + "Opacity", + current_opacity, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::BrushOpacityChanged, + ); + let hardness_slider = plain_slider( + "Hardness", + current_hardness, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::BrushHardnessChanged, + ); // Live preview — show current brush tip let preview_handle = get_brush_preview(current_style); @@ -451,17 +647,13 @@ pub fn view( .height(80) .center_x(Length::Fill) .center_y(Length::Fill) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_active)), - border: iced::Border::default().color(colors.accent).width(1), - ..Default::default() - }); + .style(move |_theme| styles::recessed_control(colors)); // Panel title is in the dock tab — no duplicate inside the panel. let panel = column![ tabs, horizontal_rule(1), - scrollable(grid_rows).height(Length::Fill), + scrollable(column![grid_rows, imported_rows].spacing(6)).height(Length::Fill), horizontal_rule(1), row![button(text("Import ABR").size(10)) .on_press(Message::BrushImportAbr) @@ -487,11 +679,8 @@ pub fn view( .spacing(4), text("Preview").size(10), live_preview, - text(format!("Size: {:.0}", current_size)).size(10), size_slider, - text(format!("Opacity: {:.0}%", current_opacity * 100.0)).size(10), opacity_slider, - text(format!("Hardness: {:.0}%", current_hardness * 100.0)).size(10), hardness_slider, ] .spacing(2) @@ -503,3 +692,33 @@ pub fn view( .style(move |_theme| styles::panel_background(colors)) .into() } + +#[cfg(test)] +mod tests { + use super::{category_matches_style, BrushCategory, BRUSH_STYLES}; + + #[test] + fn catalog_contains_every_engine_brush_style() { + assert_eq!(BRUSH_STYLES.len(), 34); + } + + #[test] + fn category_filter_keeps_expected_groups() { + assert!(category_matches_style( + BrushCategory::All, + BrushCategory::Effects + )); + assert!(category_matches_style( + BrushCategory::Drawing, + BrushCategory::Drawing + )); + assert!(!category_matches_style( + BrushCategory::Painting, + BrushCategory::Drawing + )); + assert!(category_matches_style( + BrushCategory::Imported, + BrushCategory::Imported + )); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs index d2ab356..ac5d522 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs @@ -21,7 +21,7 @@ use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; use hcie_engine_api::Tool; -use iced::widget::{button, column, container, row, scrollable, svg, text, Space}; +use iced::widget::{button, column, container, responsive, row, scrollable, svg, text, Space}; use iced::{Element, Length}; use std::borrow::Cow; @@ -53,14 +53,6 @@ pub fn view( .into(); } - let mut grid = column![].spacing(4); - for entry in shapes { - let idx = entry.index as usize; - let is_selected = selected_index == Some(idx); - let card = shape_card(idx, &entry.kind, &entry.svg, is_selected, colors); - grid = grid.push(card); - } - let refresh_btn = button(text("Refresh").size(9).color(colors.text_primary)) .on_press(Message::RefreshCustomShapes) .padding([2, 6]) @@ -93,7 +85,14 @@ pub fn view( column![ header, text(shape_dir_hint()).size(9).color(colors.text_secondary), - scrollable(grid).width(Length::Fill).height(Length::Fill), + scrollable( + container(responsive(move |size| { + shape_grid(shapes, selected_index, colors, size.width) + })) + .height(Length::Shrink), + ) + .width(Length::Fill) + .height(Length::Fill), ] .spacing(4) .width(Length::Fill), @@ -105,6 +104,55 @@ pub fn view( .into() } +/// Builds an equal-width grid that reflows from the current panel width. +/// +/// **Purpose:** Keeps custom shapes compact and visible in both narrow dock panes and wider +/// floating panels. **Logic & Workflow:** Calculates a safe column count, fills rows with shape +/// cards, and pads the final row with flexible spacers so its cells retain the same width. +/// **Arguments:** `shapes` is the engine catalog, `selected_index` marks the active shape, +/// `colors` supplies theme tokens, and `available_width` is reported by Iced's responsive widget. +/// **Returns:** A column of responsive grid rows. **Side Effects / Dependencies:** None. +fn shape_grid<'a>( + shapes: &'a [hcie_engine_api::ShapeEntry], + selected_index: Option, + colors: ThemeColors, + available_width: f32, +) -> Element<'a, Message> { + const CELL_MIN_WIDTH: f32 = 60.0; + const GAP: f32 = 3.0; + + let columns = grid_column_count(available_width, CELL_MIN_WIDTH, GAP); + let mut rows = column![].spacing(GAP); + + for chunk in shapes.chunks(columns) { + let mut grid_row = row![].spacing(GAP).width(Length::Fill); + for entry in chunk { + let index = entry.index as usize; + grid_row = grid_row.push(shape_card( + index, + &entry.kind, + &entry.svg, + selected_index == Some(index), + colors, + )); + } + for _ in chunk.len()..columns { + grid_row = grid_row.push(Space::with_width(Length::Fill)); + } + rows = rows.push(grid_row); + } + + rows.width(Length::Fill).into() +} + +/// Calculates how many minimum-width cards fit in a responsive row. +/// +/// **Arguments:** `available_width` is the row width, `cell_min_width` is the target card width, +/// and `gap` is the horizontal spacing. **Returns:** At least one column. **Side Effects:** None. +fn grid_column_count(available_width: f32, cell_min_width: f32, gap: f32) -> usize { + (((available_width + gap) / (cell_min_width + gap)).floor() as usize).max(1) +} + /// Render one shape card with a small SVG preview and label. fn shape_card<'a>( index: usize, @@ -118,45 +166,36 @@ fn shape_card<'a>( let handle = svg::Handle::from_memory(svg_bytes); let preview = iced::widget::Svg::new(handle) - .width(Length::Fixed(40.0)) - .height(Length::Fixed(40.0)) + .width(Length::Fixed(36.0)) + .height(Length::Fixed(36.0)) .style(move |_theme, _status| svg::Style { color: Some(colors.text_primary), }); // Label derived from the shape kind, matching the catalog's capitalization. - let label = text(kind.replace('_', " ")).size(10).color(colors.text_primary); + let label = text(kind.replace('_', " ")) + .size(10) + .color(colors.text_primary); let content = column![ container(preview) .width(Length::Fill) .center_x(Length::Fill), - container(label) - .width(Length::Fill) - .center_x(Length::Fill), + container(label).width(Length::Fill).center_x(Length::Fill), ] .spacing(2) .width(Length::Fill); - let border_color = if is_selected { colors.accent } else { colors.border_low }; - let bg_color = if is_selected { colors.bg_active } else { colors.bg_panel }; - button( container(content) .width(Length::Fill) .padding(4) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(bg_color)), - border: iced::Border::default() - .color(border_color) - .width(if is_selected { 2 } else { 1 }) - .rounded(4), - ..Default::default() - }), + .style(move |_theme| styles::raised_card(colors, is_selected)), ) .on_press(Message::ToolSelected(Tool::CustomShape(index as u32))) .padding(0) .width(Length::Fill) + .height(Length::Fixed(70.0)) .style(move |_theme, _status| iced::widget::button::Style { background: None, ..Default::default() @@ -164,6 +203,18 @@ fn shape_card<'a>( .into() } +#[cfg(test)] +mod tests { + use super::grid_column_count; + + #[test] + fn responsive_grid_adds_columns_as_width_grows() { + assert_eq!(grid_column_count(58.0, 60.0, 3.0), 1); + assert_eq!(grid_column_count(136.0, 60.0, 3.0), 2); + assert_eq!(grid_column_count(205.0, 60.0, 3.0), 3); + } +} + /// Return the user-facing path hint for custom shape files. fn shape_dir_hint() -> String { dirs::data_local_dir() 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 88eaf80..3f8727f 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 @@ -49,7 +49,7 @@ fn float_slider( min: f64, max: f64, step: f64, - _colors: ThemeColors, + colors: ThemeColors, ) -> Element<'static, Message> { let key_owned = key.to_string(); let filter_owned = filter_id.to_string(); @@ -62,12 +62,10 @@ fn float_slider( step_f, "", 1, + colors, move |v| { let rounded = (v as f64 / step).round() * step; - Message::FilterParamChanged( - filter_owned.clone(), - json!({ key_owned.clone(): rounded }), - ) + Message::FilterParamChanged(filter_owned.clone(), json!({ key_owned.clone(): rounded })) }, ) } @@ -80,7 +78,7 @@ fn int_slider( value: u64, min: u64, max: u64, - _colors: ThemeColors, + colors: ThemeColors, ) -> Element<'static, Message> { let key_owned = key.to_string(); let filter_owned = filter_id.to_string(); @@ -92,6 +90,7 @@ fn int_slider( 1.0, "", 0, + colors, move |v| { Message::FilterParamChanged( filter_owned.clone(), @@ -996,8 +995,5 @@ pub fn view( } }; - container(content) - .width(Length::Fill) - .padding(4) - .into() + container(content).width(Length::Fill).padding(4).into() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs index dcf9809..da5c6f5 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs @@ -10,10 +10,10 @@ use crate::color_picker::ColorPickerTarget; use crate::panels::styles; use crate::panels::typography::{BODY, SECTION}; use crate::theme::ThemeColors; +use crate::widgets::plain_slider::plain_slider; use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape}; use iced::widget::{ - button, checkbox, column, container, pick_list, row, scrollable, slider, text, text_input, - Space, + button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space, }; use iced::{Element, Length}; @@ -64,17 +64,9 @@ fn labeled_slider<'a>( value: f32, range: std::ops::RangeInclusive, on_change: fn(f32) -> Message, + colors: ThemeColors, ) -> Element<'a, Message> { - row![ - text(label).size(BODY).width(42), - slider(range, value, on_change) - .step(0.5) - .width(Length::Fill), - text(format!("{:.0}", value)).size(BODY).width(38), - ] - .spacing(4) - .align_y(iced::Alignment::Center) - .into() + plain_slider(label, value, range, 0.5, "", 1, colors, on_change) } /// Build the geometry panel. @@ -127,9 +119,8 @@ pub fn view( .on_press(Message::VectorShapeDelete) .padding([2, 6]), ); - content = content.push( - container(header.spacing(4).width(Length::Fill)).padding([2, 0]), - ); + content = + content.push(container(header.spacing(4).width(Length::Fill)).padding([2, 0])); } // Shape list — rendered front-to-back so the topmost (last index) @@ -334,24 +325,28 @@ pub fn view( x1, -2000.0..=5000.0, Message::GeometryBoundsX1, + colors, )); content = content.push(labeled_slider( "Y1", y1, -2000.0..=5000.0, Message::GeometryBoundsY1, + colors, )); content = content.push(labeled_slider( "X2", x2, -2000.0..=5000.0, Message::GeometryBoundsX2, + colors, )); content = content.push(labeled_slider( "Y2", y2, -2000.0..=5000.0, Message::GeometryBoundsY2, + colors, )); // Size display @@ -406,6 +401,7 @@ pub fn view( cur_opacity, 0.0..=1.0, Message::GeometryOpacityChanged, + colors, )); // Hardness slider @@ -415,6 +411,7 @@ pub fn view( cur_hardness, 0.0..=1.0, Message::GeometryHardnessChanged, + colors, )); // Line caps (for Line shapes only) 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 02baa87..d935880 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 @@ -36,11 +36,7 @@ pub fn view<'a>( label }; - let item_style = if is_current { - styles::active_item_bg(colors) - } else { - styles::inactive_item_bg(colors) - }; + let item_style = styles::raised_card(colors, is_current); let item = container(label) .width(Length::Fill) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs index f8b3458..ab18811 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs @@ -11,7 +11,7 @@ use crate::panels::styles; use crate::theme::ThemeColors; use hcie_engine_api::LayerStyle; use iced::widget::{ - button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space, + button, column, container, horizontal_rule, pick_list, row, scrollable, text, Space, }; use iced::{Element, Length}; @@ -346,7 +346,7 @@ pub fn view<'a>( let ox = drag_offset.0; let oy = drag_offset.1; - let dialog_container = container(dialog) + let dialog_surface = container(Space::with_width(580).height(420)) .width(Length::Fixed(580.0)) .height(Length::Fixed(420.0)) .style(move |_theme| iced::widget::container::Style { @@ -362,6 +362,11 @@ pub fn view<'a>( }, ..Default::default() }); + let dialog_container = iced::widget::Stack::new() + .push(dialog_surface) + .push(dialog) + .width(580) + .height(420); // Apply horizontal offset using Space and doubling technique let dialog_row = if ox >= 0.0 { @@ -380,13 +385,27 @@ pub fn view<'a>( column![dialog_row, Space::with_height(Length::Fixed(-oy * 2.0)),] }; - let backdrop = container(dialog_positioned) + let backdrop = container(Space::with_width(Length::Fill).height(Length::Fill)) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.62, + ))), + ..Default::default() + }); + let positioned_dialog = container(dialog_positioned) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill); - backdrop.into() + iced::widget::Stack::new() + .push(backdrop) + .push(positioned_dialog) + .width(Length::Fill) + .height(Length::Fill) + .into() } /// Build parameter controls for a specific style. @@ -855,25 +874,10 @@ fn slider_row<'a>( value: f32, range: std::ops::RangeInclusive, on_change: impl Fn(f32) -> Message + 'static, - _colors: ThemeColors, + colors: ThemeColors, ) -> Element<'a, Message> { - let value_label = text(format!("{:.1}", value)) - .size(9) - .width(Length::Fixed(40.0)); - let label_text = text(label).size(10).width(Length::Fixed(70.0)); - let step = if *range.end() <= 1.0 { 0.01 } else { 1.0 }; - - row![ - label_text, - slider(range, value, on_change) - .step(step) - .width(Length::Fill), - value_label, - ] - .spacing(3) - .align_y(iced::Alignment::Center) - .into() + crate::widgets::plain_slider::plain_slider(label, value, range, step, "", 1, colors, on_change) } /// Docked Layer Styles panel — lists effects with enable checkboxes. @@ -881,10 +885,7 @@ fn slider_row<'a>( /// **Purpose:** A simplified, always-visible panel showing all layer effects /// with toggle checkboxes. This is the docked counterpart to the floating /// `view()` dialog; it provides quick enable/disable without full parameter editing. -pub fn panel_view<'a>( - styles_list: Vec, - colors: ThemeColors, -) -> Element<'a, Message> { +pub fn panel_view<'a>(styles_list: Vec, colors: ThemeColors) -> Element<'a, Message> { let mut effects_list = column![].spacing(1); for (i, &(disc, label, color_arr)) in STYLE_DEFS.iter().enumerate() { @@ -895,16 +896,17 @@ pub fn panel_view<'a>( .on_toggle(move |_| Message::LayerStyleToggle(i as u32)) .size(11); - let label_text = text(label) - .size(11) - .width(Length::Fill) - .style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(if enabled { - badge_color - } else { - colors.text_secondary - }), - }); + let label_text = + text(label) + .size(11) + .width(Length::Fill) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(if enabled { + badge_color + } else { + colors.text_secondary + }), + }); let item_row = row![toggle, label_text] .spacing(4) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs index f04b34e..6e66d78 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs @@ -228,6 +228,7 @@ pub fn view<'a>( 0.01, "%", 0, + colors, move |v| Message::LayerSetOpacity(active_layer_id, v), ); @@ -291,11 +292,18 @@ pub fn view<'a>( }; // Visibility toggle (eye icon) - let vis_char = if info.visible { "\u{1F441}" } else { "\u{1F441}" }; + let vis_char = if info.visible { + "\u{1F441}" + } else { + "\u{1F441}" + }; let vis_color = if info.visible { colors.text_primary } else { - iced::Color { a: colors.text_secondary.a * 0.4, ..colors.text_secondary } + iced::Color { + a: colors.text_secondary.a * 0.4, + ..colors.text_secondary + } }; let vis_btn = button(text(vis_char).size(11).style(move |_theme: &iced::Theme| { iced::widget::text::Style { 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 e2f45f7..4fa1a4f 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 @@ -17,7 +17,7 @@ use crate::dock::state::{DockState, PaneType}; use crate::panels::typography; use crate::theme::ThemeColors; use hcie_engine_api::Tool; -use iced::widget::{column, container, horizontal_rule, mouse_area, row, text, Space}; +use iced::widget::{column, container, horizontal_rule, mouse_area, row, scrollable, text, Space}; use iced::{Element, Length}; /// Identifies one implemented menu operation independently of menu position or label. @@ -341,7 +341,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste), MenuItem::command("Clear", MenuCommand::ClearPixels), MenuItem::separator(), - MenuItem::command_with_shortcut("Paste Special", "Shift+Ctrl+V", MenuCommand::PasteSpecial), + MenuItem::command_with_shortcut( + "Paste Special", + "Shift+Ctrl+V", + MenuCommand::PasteSpecial, + ), MenuItem::separator(), MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill), MenuItem::command("Stroke...", MenuCommand::Stroke), @@ -349,7 +353,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Content-Aware Scale"), MenuItem::new("Puppet Warp"), MenuItem::new("Perspective Warp"), - MenuItem::command_with_shortcut("Free Transform", "Alt+Ctrl+T", MenuCommand::FreeTransform), + MenuItem::command_with_shortcut( + "Free Transform", + "Alt+Ctrl+T", + MenuCommand::FreeTransform, + ), MenuItem::submenu("Transform"), MenuItem::new("Auto-Align"), MenuItem::new("Auto-Blend"), @@ -359,7 +367,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::separator(), MenuItem::submenu("Define New"), MenuItem::new("Preset Manager..."), - MenuItem::command_with_shortcut("Preferences...", "Ctrl+K", MenuCommand::Preferences), + MenuItem::command_with_shortcut( + "Preferences...", + "Ctrl+K", + MenuCommand::Preferences, + ), MenuItem::new("Local Storage..."), ], }, @@ -367,28 +379,72 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuDef { label: "Tools".to_string(), items: vec![ - MenuItem::command_with_shortcut("Eyedropper", "I", MenuCommand::SelectTool(Tool::Eyedropper)), - MenuItem::command_with_shortcut("Pencil / Pen", "P", MenuCommand::SelectTool(Tool::Pen)), - MenuItem::command_with_shortcut("Art Brush", "B", MenuCommand::SelectTool(Tool::Brush)), - MenuItem::command_with_shortcut("Eraser", "E", MenuCommand::SelectTool(Tool::Eraser)), + MenuItem::command_with_shortcut( + "Eyedropper", + "I", + MenuCommand::SelectTool(Tool::Eyedropper), + ), + MenuItem::command_with_shortcut( + "Pencil / Pen", + "P", + MenuCommand::SelectTool(Tool::Pen), + ), + MenuItem::command_with_shortcut( + "Art Brush", + "B", + MenuCommand::SelectTool(Tool::Brush), + ), + MenuItem::command_with_shortcut( + "Eraser", + "E", + MenuCommand::SelectTool(Tool::Eraser), + ), MenuItem::command("Spray", MenuCommand::SelectTool(Tool::Spray)), - MenuItem::command_with_shortcut("Flood Fill", "F", MenuCommand::SelectTool(Tool::FloodFill)), - MenuItem::command_with_shortcut("Magic Wand", "W", MenuCommand::SelectTool(Tool::MagicWand)), - MenuItem::command_with_shortcut("Rect Selection", "M", MenuCommand::SelectTool(Tool::Select)), + MenuItem::command_with_shortcut( + "Flood Fill", + "F", + MenuCommand::SelectTool(Tool::FloodFill), + ), + MenuItem::command_with_shortcut( + "Magic Wand", + "W", + MenuCommand::SelectTool(Tool::MagicWand), + ), + MenuItem::command_with_shortcut( + "Rect Selection", + "M", + MenuCommand::SelectTool(Tool::Select), + ), MenuItem::command_with_shortcut("Lasso", "L", MenuCommand::SelectTool(Tool::Lasso)), - MenuItem::command("Polygon Select", MenuCommand::SelectTool(Tool::PolygonSelect)), + MenuItem::command( + "Polygon Select", + MenuCommand::SelectTool(Tool::PolygonSelect), + ), MenuItem::command_with_shortcut("Move", "V", MenuCommand::SelectTool(Tool::Move)), MenuItem::command_with_shortcut("Crop", "C", MenuCommand::SelectTool(Tool::Crop)), MenuItem::command_with_shortcut("Text", "T", MenuCommand::SelectTool(Tool::Text)), - MenuItem::command_with_shortcut("Gradient", "G", MenuCommand::SelectTool(Tool::Gradient)), + MenuItem::command_with_shortcut( + "Gradient", + "G", + MenuCommand::SelectTool(Tool::Gradient), + ), MenuItem::separator(), MenuItem::disabled("Retouch"), - MenuItem::command("Red-eye Removal", MenuCommand::SelectTool(Tool::RedEyeRemoval)), - MenuItem::command("Spot Removal Patch", MenuCommand::SelectTool(Tool::SpotRemoval)), + MenuItem::command( + "Red-eye Removal", + MenuCommand::SelectTool(Tool::RedEyeRemoval), + ), + MenuItem::command( + "Spot Removal Patch", + MenuCommand::SelectTool(Tool::SpotRemoval), + ), MenuItem::command("Smart Patch", MenuCommand::SelectTool(Tool::SmartPatch)), MenuItem::separator(), MenuItem::disabled("AI"), - MenuItem::command("AI Object Removal", MenuCommand::SelectTool(Tool::AiObjectRemoval)), + MenuItem::command( + "AI Object Removal", + MenuCommand::SelectTool(Tool::AiObjectRemoval), + ), MenuItem::command("Smart Select", MenuCommand::SelectTool(Tool::SmartSelect)), MenuItem::command("Vision Select", MenuCommand::SelectTool(Tool::VisionSelect)), ], @@ -460,11 +516,20 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::with_shortcut("Group Layers", "Ctrl+G"), MenuItem::submenu("Arrange"), MenuItem::command("Align Left", MenuCommand::AlignLayer("left".to_string())), - MenuItem::command("Align Center", MenuCommand::AlignLayer("center".to_string())), + MenuItem::command( + "Align Center", + MenuCommand::AlignLayer("center".to_string()), + ), MenuItem::command("Align Right", MenuCommand::AlignLayer("right".to_string())), MenuItem::command("Align Top", MenuCommand::AlignLayer("top".to_string())), - MenuItem::command("Align Middle", MenuCommand::AlignLayer("middle".to_string())), - MenuItem::command("Align Bottom", MenuCommand::AlignLayer("bottom".to_string())), + MenuItem::command( + "Align Middle", + MenuCommand::AlignLayer("middle".to_string()), + ), + MenuItem::command( + "Align Bottom", + MenuCommand::AlignLayer("bottom".to_string()), + ), MenuItem::submenu("Combine Shapes"), MenuItem::submenu("Animation"), MenuItem::separator(), @@ -557,9 +622,18 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { // ── Blur ── MenuItem::disabled("Blur"), MenuItem::command("Box Blur", MenuCommand::FilterDistort("box_blur".into())), - MenuItem::command("Gaussian Blur", MenuCommand::FilterDistort("gaussian_blur".into())), - MenuItem::command("Motion Blur", MenuCommand::FilterDistort("motion_blur".into())), - MenuItem::command("Unsharp Mask", MenuCommand::FilterRestore("unsharp_mask".into())), + MenuItem::command( + "Gaussian Blur", + MenuCommand::FilterDistort("gaussian_blur".into()), + ), + MenuItem::command( + "Motion Blur", + MenuCommand::FilterDistort("motion_blur".into()), + ), + MenuItem::command( + "Unsharp Mask", + MenuCommand::FilterRestore("unsharp_mask".into()), + ), MenuItem::separator(), // ── Distort ── MenuItem::disabled("Distort"), @@ -571,11 +645,17 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::command("Add Noise", MenuCommand::FilterNoise("add_noise".into())), MenuItem::command("Median Filter", MenuCommand::FilterRestore("median".into())), MenuItem::command("Dehaze", MenuCommand::FilterRestore("dehaze".into())), - MenuItem::command("Noise Pattern", MenuCommand::FilterNoise("noise_pattern".into())), + MenuItem::command( + "Noise Pattern", + MenuCommand::FilterNoise("noise_pattern".into()), + ), MenuItem::separator(), // ── Pixelate ── MenuItem::disabled("Pixelate"), - MenuItem::command("Crystallize", MenuCommand::FilterPixelate("crystallize".into())), + MenuItem::command( + "Crystallize", + MenuCommand::FilterPixelate("crystallize".into()), + ), MenuItem::command("Mosaic", MenuCommand::FilterPixelate("mosaic".into())), MenuItem::separator(), // ── Render ── @@ -585,7 +665,10 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { // ── Procedural Textures (flat-grouped; ICED menus are single-level) ── MenuItem::disabled("Procedural Textures"), MenuItem::disabled("Natural"), - MenuItem::command("Clouds Texture", MenuCommand::FilterTexture("texture_clouds".into())), + MenuItem::command( + "Clouds Texture", + MenuCommand::FilterTexture("texture_clouds".into()), + ), MenuItem::command("Grass", MenuCommand::FilterTexture("texture_grass".into())), MenuItem::command("Sand", MenuCommand::FilterTexture("texture_sand".into())), MenuItem::command("Dirt", MenuCommand::FilterTexture("texture_dirt".into())), @@ -594,34 +677,61 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::command("Stone", MenuCommand::FilterTexture("texture_stone".into())), MenuItem::separator(), MenuItem::disabled("Architecture"), - MenuItem::command("Bricks", MenuCommand::FilterTexture("texture_bricks".into())), + MenuItem::command( + "Bricks", + MenuCommand::FilterTexture("texture_bricks".into()), + ), MenuItem::separator(), MenuItem::disabled("Wood"), MenuItem::command("Oak", MenuCommand::FilterTexture("texture_wood_oak".into())), - MenuItem::command("Pine", MenuCommand::FilterTexture("texture_wood_pine".into())), - MenuItem::command("Dark", MenuCommand::FilterTexture("texture_wood_dark".into())), + MenuItem::command( + "Pine", + MenuCommand::FilterTexture("texture_wood_pine".into()), + ), + MenuItem::command( + "Dark", + MenuCommand::FilterTexture("texture_wood_dark".into()), + ), MenuItem::separator(), MenuItem::disabled("Metal"), - MenuItem::command("Steel", MenuCommand::FilterTexture("texture_metal_steel".into())), - MenuItem::command("Gold", MenuCommand::FilterTexture("texture_metal_gold".into())), - MenuItem::command("Copper", MenuCommand::FilterTexture("texture_metal_copper".into())), + MenuItem::command( + "Steel", + MenuCommand::FilterTexture("texture_metal_steel".into()), + ), + MenuItem::command( + "Gold", + MenuCommand::FilterTexture("texture_metal_gold".into()), + ), + MenuItem::command( + "Copper", + MenuCommand::FilterTexture("texture_metal_copper".into()), + ), MenuItem::separator(), MenuItem::disabled("Material"), MenuItem::command("Linen", MenuCommand::FilterTexture("texture_linen".into())), MenuItem::command("Glass", MenuCommand::FilterTexture("texture_glass".into())), MenuItem::separator(), MenuItem::disabled("Effect"), - MenuItem::command("Sky Replacement", MenuCommand::FilterTexture("sky_replacement".into())), + MenuItem::command( + "Sky Replacement", + MenuCommand::FilterTexture("sky_replacement".into()), + ), MenuItem::separator(), // ── Sharpen / Restore ── MenuItem::disabled("Sharpen / Restore"), MenuItem::command("Sharpen", MenuCommand::FilterRestore("sharpen".into())), - MenuItem::command("Smart Patch", MenuCommand::FilterRestore("smart_patch".into())), + MenuItem::command( + "Smart Patch", + MenuCommand::FilterRestore("smart_patch".into()), + ), MenuItem::separator(), // ── Stylize ── MenuItem::disabled("Stylize"), MenuItem::command("Emboss", MenuCommand::FilterStylize("emboss".into())), - MenuItem::command("Find Edges", MenuCommand::FilterStylize("find_edges".into())), + MenuItem::command( + "Find Edges", + MenuCommand::FilterStylize("find_edges".into()), + ), MenuItem::command("Oil Paint", MenuCommand::FilterStylize("oil_paint".into())), MenuItem::command("Posterize", MenuCommand::FilterStylize("posterize".into())), MenuItem::command("Threshold", MenuCommand::FilterStylize("threshold".into())), @@ -631,11 +741,23 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::command("Levels", MenuCommand::FilterRender("levels".into())), MenuItem::command("Vibrance", MenuCommand::FilterRender("vibrance".into())), MenuItem::command("Exposure", MenuCommand::FilterRender("exposure".into())), - MenuItem::command("Gamma Correction", MenuCommand::FilterRender("gamma".into())), + MenuItem::command( + "Gamma Correction", + MenuCommand::FilterRender("gamma".into()), + ), MenuItem::command("Black & White", MenuCommand::FilterStylize("bw".into())), - MenuItem::command("Gradient Map", MenuCommand::FilterRender("gradient_map".into())), - MenuItem::command("Channel Mixer", MenuCommand::FilterRender("channel_mixer".into())), - MenuItem::command("Selective Color", MenuCommand::FilterRender("selective_color".into())), + MenuItem::command( + "Gradient Map", + MenuCommand::FilterRender("gradient_map".into()), + ), + MenuItem::command( + "Channel Mixer", + MenuCommand::FilterRender("channel_mixer".into()), + ), + MenuItem::command( + "Selective Color", + MenuCommand::FilterRender("selective_color".into()), + ), MenuItem::separator(), // ── Fourier ── MenuItem::disabled("Fourier"), @@ -781,11 +903,20 @@ pub fn dropdown_overlay( colors: ThemeColors, dock: Option<&DockState>, viewport_width: f32, + viewport_height: f32, ) -> Option> { let menu_idx = active_menu?; let menus = all_menus(recent_files); let menu_items = menus.get(menu_idx)?.items.clone(); + let separator_count = menu_items.iter().filter(|item| item.label == "─").count(); + let normal_count = menu_items.len().saturating_sub(separator_count); + let natural_height = + normal_count as f32 * typography::MENU_ROW_HEIGHT + separator_count as f32 * 5.0 + 2.0; + let available_height = + (viewport_height - crate::panels::title_bar::MENU_BAR_HEIGHT - 8.0).max(80.0); + let menu_height = natural_height.min(available_height); + let mut items: Vec> = Vec::new(); let mut item_idx = 0; @@ -888,10 +1019,9 @@ pub fn dropdown_overlay( } let col = column(items).spacing(0); - - // Photopea-style dropdown: light gray background with border and shadow - let dropdown = container(col) + let surface = container(Space::with_width(260).height(menu_height)) .width(260) + .height(menu_height) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.menu_bg)), border: iced::Border::default().color(colors.border_high).width(1), @@ -902,6 +1032,12 @@ pub fn dropdown_overlay( }, ..Default::default() }); + let visible_items = scrollable(col).width(260).height(menu_height); + let dropdown = iced::widget::Stack::new() + .push(surface) + .push(visible_items) + .width(260) + .height(menu_height); let left_offset = crate::panels::title_bar::menu_anchor_x(menu_idx, viewport_width, 260.0); let backdrop = mouse_area(container(text("")).width(Length::Fill).height(Length::Fill)) 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 b79ac93..191e3b2 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 @@ -136,26 +136,22 @@ pub fn view<'a>( Tool::Pen | Tool::Brush | Tool::Eraser => { brush_properties(brush_size, brush_opacity, brush_hardness, colors) } - Tool::Spray => { - spray_properties( - brush_size, - brush_opacity, - brush_hardness, - settings_spray_particle_size, - settings_spray_density, - colors, - ) - } + Tool::Spray => spray_properties( + brush_size, + brush_opacity, + brush_hardness, + settings_spray_particle_size, + settings_spray_density, + colors, + ), Tool::Select => selection_properties(selection_rect, colors), - Tool::VectorSelect => { - vector_select_properties( - selected_vector_shape, - vector_shapes, - vector_angle, - active_layer_id, - colors, - ) - } + Tool::VectorSelect => vector_select_properties( + selected_vector_shape, + vector_shapes, + vector_angle, + active_layer_id, + colors, + ), Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine @@ -223,7 +219,7 @@ fn layer_info_section<'a>( width: u32, height: u32, layer_id: u64, - _colors: ThemeColors, + colors: ThemeColors, ) -> Element<'a, Message> { let opacity_slider = plain_slider( "Opacity", @@ -232,6 +228,7 @@ fn layer_info_section<'a>( 0.01, "%", 0, + colors, move |v| Message::LayerSetOpacity(layer_id, v), ); @@ -342,7 +339,8 @@ fn vector_select_properties<'a>( 0.5, "px", 1, - move |v| Message::VectorStrokeChanged(v), + colors, + move |v| { Message::VectorStrokeChanged(v) }, ), plain_slider( "Opacity", @@ -351,7 +349,8 @@ fn vector_select_properties<'a>( 0.01, "%", 0, - move |v| Message::VectorOpacityChanged(v), + colors, + move |v| { Message::VectorOpacityChanged(v) }, ), plain_slider( "Hardness", @@ -360,7 +359,8 @@ fn vector_select_properties<'a>( 0.01, "%", 0, - move |v| Message::GeometryHardnessChanged(v), + colors, + move |v| { Message::GeometryHardnessChanged(v) }, ), plain_slider( "Angle", @@ -369,6 +369,7 @@ fn vector_select_properties<'a>( 1.0, "°", 0, + colors, move |v| Message::VectorAngleChanged(v), ), row![ @@ -418,30 +419,18 @@ fn vector_tool_properties<'a>( points: u32, sides: u32, vector_draw: Option<((f32, f32), (f32, f32))>, - _colors: ThemeColors, + colors: ThemeColors, foreground_color: [u8; 4], background_color: [u8; 4], ) -> Element<'a, Message> { let mut col = column![ text("Vector Shape").size(11), - 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), - ), + plain_slider("Stroke", stroke, 0.0..=50.0, 0.5, "px", 1, colors, |v| { + Message::VectorStrokeChanged(v) + },), + plain_slider("Opacity", opacity, 0.0..=1.0, 0.01, "%", 0, colors, |v| { + Message::VectorOpacityChanged(v) + },), row![ text("Fill").size(10), checkbox("", fill) @@ -479,6 +468,7 @@ fn vector_tool_properties<'a>( 1.0, "px", 1, + colors, |v| Message::VectorRadiusChanged(v), )); } @@ -490,6 +480,7 @@ fn vector_tool_properties<'a>( 1.0, "", 0, + colors, |v| Message::VectorPointsChanged(v as u32), )); } @@ -501,6 +492,7 @@ fn vector_tool_properties<'a>( 1.0, "", 0, + colors, |v| Message::VectorSidesChanged(v as u32), )); } @@ -524,37 +516,19 @@ fn brush_properties<'a>( size: f32, opacity: f32, hardness: f32, - _colors: ThemeColors, + colors: ThemeColors, ) -> Element<'a, Message> { column![ text("Brush Properties").size(11), - 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), - ), + plain_slider("Size", size, 1.0..=200.0, 1.0, "px", 0, colors, |v| { + Message::BrushSizeChanged(v) + },), + plain_slider("Opacity", opacity, 0.0..=1.0, 0.01, "%", 0, colors, |v| { + Message::BrushOpacityChanged(v) + },), + plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| { + Message::BrushHardnessChanged(v) + },), ] .spacing(4) .into() @@ -567,19 +541,13 @@ fn spray_properties<'a>( hardness: f32, particle_size: f32, density: u32, - _colors: ThemeColors, + colors: ThemeColors, ) -> Element<'a, Message> { column![ text("Spray Properties").size(11), - plain_slider( - "Size", - size, - 1.0..=200.0, - 1.0, - "px", - 0, - |v| Message::BrushSizeChanged(v), - ), + plain_slider("Size", size, 1.0..=200.0, 1.0, "px", 0, colors, |v| { + Message::BrushSizeChanged(v) + },), plain_slider( "Particle Size", particle_size, @@ -587,6 +555,7 @@ fn spray_properties<'a>( 0.1, "px", 1, + colors, Message::SprayParticleSizeChanged, ), plain_slider( @@ -596,26 +565,15 @@ fn spray_properties<'a>( 1.0, "", 0, - |v| Message::SprayDensityChanged(v as u32), - ), - 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), + colors, + |v| { Message::SprayDensityChanged(v as u32) }, ), + plain_slider("Opacity", opacity, 0.0..=1.0, 0.01, "%", 0, colors, |v| { + Message::BrushOpacityChanged(v) + },), + plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| { + Message::BrushHardnessChanged(v) + },), ] .spacing(4) .into() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/status_bar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/status_bar.rs index 075d726..e4157b7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/status_bar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/status_bar.rs @@ -4,8 +4,9 @@ use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; +use crate::widgets::plain_slider::plain_slider; use hcie_engine_api::Tool; -use iced::widget::{container, row, slider, text}; +use iced::widget::{container, row, text}; use iced::{Element, Length}; /// Build the status bar element. @@ -27,16 +28,23 @@ pub fn view<'a>( let zoom_pct = text(format!("{:.0}%", zoom * 100.0)).size(11); let canvas_size = text(format!("{}×{}", canvas_w, canvas_h)).size(11); - let zoom_slider = slider(0.01..=16.0, zoom, |v| Message::CanvasZoomSet(v)) - .step(0.01) - .width(100); + let zoom_slider = container(plain_slider( + "Zoom", + zoom, + 0.01..=16.0, + 0.01, + "×", + 2, + colors, + Message::CanvasZoomSet, + )) + .width(120); let bar = row![ tool_name, text(" | ").size(11), coords, text(" ").size(11).width(Length::Fill), - text("Zoom:").size(11), zoom_slider, zoom_pct, text(" | ").size(11), 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 0db56bd..365c650 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 @@ -101,6 +101,48 @@ pub fn panel_background(colors: ThemeColors) -> iced::widget::container::Style { iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_panel)), border: iced::Border::default().color(colors.border_low).width(1), + shadow: iced::Shadow { + color: colors.surface_shadow(), + offset: iced::Vector::new(0.0, 1.0), + blur_radius: 3.0, + }, + ..Default::default() + } +} + +/// Raised card surface with a soft highlight edge and compact shadow. +pub fn raised_card(colors: ThemeColors, selected: bool) -> iced::widget::container::Style { + iced::widget::container::Style { + background: Some(iced::Background::Color(if selected { + colors.bg_active + } else { + colors.elevated() + })), + border: iced::Border::default() + .color(if selected { + colors.accent + } else { + colors.surface_highlight() + }) + .width(1) + .rounded(5), + shadow: iced::Shadow { + color: colors.surface_shadow(), + offset: iced::Vector::new(0.0, 2.0), + blur_radius: if selected { 6.0 } else { 4.0 }, + }, + ..Default::default() + } +} + +/// Recessed control surface for inputs, sliders, and preview wells. +pub fn recessed_control(colors: ThemeColors) -> iced::widget::container::Style { + iced::widget::container::Style { + background: Some(iced::Background::Color(colors.recessed())), + border: iced::Border::default() + .color(colors.border_high) + .width(1) + .rounded(4), ..Default::default() } } @@ -247,7 +289,7 @@ pub fn modern_panel_header(colors: ThemeColors, focused: bool) -> iced::widget:: background: Some(iced::Background::Color(if focused { colors.bg_hover } else { - colors.bg_active + colors.elevated() })), border: iced::Border { color: if focused { @@ -258,6 +300,11 @@ pub fn modern_panel_header(colors: ThemeColors, focused: bool) -> iced::widget:: width: 1.0, radius: 0.0.into(), }, + shadow: iced::Shadow { + color: colors.surface_shadow(), + offset: iced::Vector::new(0.0, 1.0), + blur_radius: 3.0, + }, ..Default::default() } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs index 6d42ef5..10ed32c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs @@ -556,6 +556,11 @@ impl Program for SvgCanvasProgram { (canvas::event::Status::Captured, None) } canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + // Only handle zoom when cursor is inside the canvas bounds so + // the adjacent scrollable node list receives its own wheel events. + let Some(_position) = position else { + return (canvas::event::Status::Ignored, None); + }; let amount = match delta { mouse::ScrollDelta::Lines { y, .. } => y, mouse::ScrollDelta::Pixels { y, .. } => y / 100.0, @@ -606,9 +611,10 @@ impl Program for SvgCanvasProgram { pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Message> { let poly_count = state.editable.polygons.len(); if poly_count == 0 { - return container(text("No polygons to edit").size(12)) - .padding(24) - .into(); + return crate::dialogs::modal( + container(text("No polygons to edit").size(12)).padding(24), + colors, + ); } let selected_poly = state.selected_poly.min(poly_count - 1); let selected_node = state @@ -775,6 +781,7 @@ pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Mes .width(Length::Fill) .height(Length::Fill) .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), border: iced::Border::default().color(colors.border_high).width(1), ..Default::default() }) @@ -789,32 +796,49 @@ pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Mes .width(920) .height(680); - let dialog = container(content).style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_panel)), - border: iced::Border::default() - .rounded(6) - .color(colors.border_high) - .width(1), - shadow: iced::Shadow { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.45), - offset: Vector::new(0.0, 5.0), - blur_radius: 16.0, - }, - ..Default::default() - }); + let dialog_surface = container(Space::with_width(920).height(680)) + .width(920) + .height(680) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + border: iced::Border::default() + .rounded(6) + .color(colors.border_high) + .width(1), + shadow: iced::Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.45), + offset: Vector::new(0.0, 5.0), + blur_radius: 16.0, + }, + ..Default::default() + }); + let dialog = iced::widget::Stack::new() + .push(dialog_surface) + .push(content) + .width(920) + .height(680); - container(dialog) + let backdrop = container(Space::with_width(Length::Fill).height(Length::Fill)) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(Color::from_rgba( + 0.0, 0.0, 0.0, 0.62, + ))), + ..Default::default() + }); + let positioned_dialog = container(dialog) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill) - .padding(20) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(Color::from_rgba( - 0.0, 0.0, 0.0, 0.58, - ))), - ..Default::default() - }) + .padding(20); + + iced::widget::Stack::new() + .push(backdrop) + .push(positioned_dialog) + .width(Length::Fill) + .height(Length::Fill) .into() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/text_editor.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/text_editor.rs index 3512c07..ed0ad74 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/text_editor.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/text_editor.rs @@ -1,7 +1,9 @@ //! Text editor panel — font, size, color, alignment, effects. use crate::app::Message; -use iced::widget::{button, column, container, horizontal_rule, row, slider, text}; +use crate::theme::ThemeColors; +use crate::widgets::plain_slider::plain_slider; +use iced::widget::{button, column, container, horizontal_rule, row, text}; use iced::{Element, Length}; /// Build the text editor panel. @@ -11,23 +13,31 @@ pub fn view( font_color: [u8; 4], _alignment: &str, orientation: &str, + colors: ThemeColors, ) -> Element<'static, Message> { - let size_slider = slider(8.0..=200.0, font_size, |v| Message::TextSizeChanged(v)) - .step(1.0) - .width(Length::Fill); + let size_slider = plain_slider( + "Size", + font_size, + 8.0..=200.0, + 1.0, + "px", + 0, + colors, + Message::TextSizeChanged, + ); let r = font_color[0]; let g = font_color[1]; let b = font_color[2]; - let color_r = slider(0..=255, r, move |v| { - Message::TextColorChanged([v, g, b, 255]) + let color_r = plain_slider("R", r as f32, 0.0..=255.0, 1.0, "", 0, colors, move |v| { + Message::TextColorChanged([v as u8, g, b, 255]) }); - let color_g = slider(0..=255, g, move |v| { - Message::TextColorChanged([r, v, b, 255]) + let color_g = plain_slider("G", g as f32, 0.0..=255.0, 1.0, "", 0, colors, move |v| { + Message::TextColorChanged([r, v as u8, b, 255]) }); - let color_b = slider(0..=255, b, move |v| { - Message::TextColorChanged([r, g, v, 255]) + let color_b = plain_slider("B", b as f32, 0.0..=255.0, 1.0, "", 0, colors, move |v| { + Message::TextColorChanged([r, g, v as u8, 255]) }); let align_left = button(text("Left").size(10)) @@ -52,12 +62,11 @@ pub fn view( let panel = column![ text("Text").size(13), horizontal_rule(1), - text(format!("Size: {:.0}", font_size)).size(10), size_slider, text("Color").size(10), - row![text("R").size(10), color_r].spacing(4), - row![text("G").size(10), color_g].spacing(4), - row![text("B").size(10), color_b].spacing(4), + color_r, + color_g, + color_b, row![align_left, align_center, align_right].spacing(4), text(format!("Orientation: {}", orient)).size(10), horizontal_rule(1), 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 a1b906c..6ce60cf 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 @@ -27,7 +27,7 @@ pub(crate) const MENU_LEFT_INSET: f32 = 6.0; /// Unified menu/title bar height shared with dropdown placement. pub(crate) const MENU_BAR_HEIGHT: f32 = 30.0; -const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +const APP_VERSION: &str = hcie_build_info::VERSION; /// Returns the clamped left edge for a menu dropdown. pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 { 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 2ab33ec..b429fc0 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 @@ -12,8 +12,9 @@ use crate::app::{Message, ToolState}; use crate::panels::styles; use crate::theme::ThemeColors; +use crate::widgets::plain_slider::plain_slider; use hcie_engine_api::Tool; -use iced::widget::{button, container, row, slider, text, text_input}; +use iced::widget::{button, container, row, text, text_input}; use iced::{Element, Length}; /// Build the Photopea-style tool options bar. @@ -35,50 +36,45 @@ pub fn _view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, let options = match tool_state.active_tool { // ── Brush / Eraser / Pen / Spray ── Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => { - let size_label = text(format!("Size: {:.0}", tool_state.brush_size)) - .size(10) - .width(Length::Fixed(60.0)); - let size_slider = slider( - 1.0..=200.0, + let size_slider = container(plain_slider( + "Size", tool_state.brush_size, + 1.0..=200.0, + 1.0, + "px", + 0, + colors, Message::BrushSizeChanged, - ) - .step(1.0) - .width(Length::Fixed(100.0)); - - let opacity_label = text(format!("Opacity: {:.0}%", tool_state.brush_opacity * 100.0)) - .size(10) - .width(Length::Fixed(70.0)); - let opacity_slider = slider( - 0.0..=1.0, - tool_state.brush_opacity, - Message::BrushOpacityChanged, - ) - .step(0.01) - .width(Length::Fixed(100.0)); - - let hardness_label = text(format!( - "Hardness: {:.0}%", - tool_state.brush_hardness * 100.0 )) - .size(10) - .width(Length::Fixed(75.0)); - let hardness_slider = slider( + .width(120); + let opacity_slider = container(plain_slider( + "Opacity", + tool_state.brush_opacity, 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::BrushOpacityChanged, + )) + .width(120); + let hardness_slider = container(plain_slider( + "Hardness", tool_state.brush_hardness, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, Message::BrushHardnessChanged, - ) - .step(0.01) - .width(Length::Fixed(80.0)); + )) + .width(120); row![ - size_label, size_slider, text(" ").size(10), - opacity_label, opacity_slider, text(" ").size(10), - hardness_label, hardness_slider, ] .spacing(2) 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 c7bd7da..53cc847 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,9 +17,7 @@ use crate::settings::AppSettings; use crate::theme::ThemeColors; use crate::widgets::plain_slider::plain_slider; use hcie_engine_api::Tool; -use iced::widget::{ - button, column, container, horizontal_rule, row, scrollable, text, text_input, -}; +use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input}; use iced::{Element, Length}; /// Build the tool settings panel for the active tool. @@ -458,12 +456,12 @@ fn prop_slider( min: f32, max: f32, msg_fn: F, - _c: ThemeColors, + colors: ThemeColors, ) -> Element<'static, Message> where F: Fn(f32) -> Message + 'static, { - plain_slider(label, value, min..=max, 0.1, "", 1, msg_fn) + plain_slider(label, value, min..=max, 0.1, "", 1, colors, msg_fn) } fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, Message> { 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 d40ccfc..53ead1c 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 @@ -8,7 +8,8 @@ use crate::app::Message; use crate::panels::menus::MenuCommand; use crate::panels::styles; use crate::theme::ThemeColors; -use iced::widget::{button, container, row, slider, svg, text}; +use crate::widgets::plain_slider::plain_slider; +use iced::widget::{button, container, row, svg, text}; use iced::{Element, Length}; /// Build the compact toolbar: SVG icon buttons + brush size + opacity in one row. @@ -46,33 +47,30 @@ pub fn view<'a>( .align_y(iced::Alignment::Center); // ── Brush size + opacity (compact, matching egui) ── - let size_value = compact_value( - format!("S {:.0}px", tool_state.brush_size), - "Brush size", - c, - ); - let size_sl = slider( - 1.0..=200.0, + let size_sl = container(plain_slider( + "Size", tool_state.brush_size, + 1.0..=200.0, + 1.0, + "px", + 0, + colors, Message::BrushSizeChanged, - ) - .step(1.0) - .width(Length::Fixed(64.0)); - - let opacity_value = compact_value( - format!("O {:.0}%", tool_state.brush_opacity * 100.0), - "Brush opacity", - c, - ); - let opacity_sl = slider( - 0.0..=1.0, + )) + .width(110); + let opacity_sl = container(plain_slider( + "Opacity", tool_state.brush_opacity, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, Message::BrushOpacityChanged, - ) - .step(0.01) - .width(Length::Fixed(64.0)); + )) + .width(110); - let tool_controls = row![size_value, size_sl, sep(c), opacity_value, opacity_sl] + let tool_controls = row![size_sl, sep(c), opacity_sl] .spacing(4) .align_y(iced::Alignment::Center); @@ -157,13 +155,3 @@ fn sep(colors: ThemeColors) -> Element<'static, Message> { }) .into() } - -/// Displays a compact toolbar value while moving its full meaning into a tooltip. -fn compact_value(value: String, tip: &str, colors: ThemeColors) -> Element<'static, Message> { - styles::balloon_tooltip( - container(text(value).size(10).color(colors.text_primary)).padding([1, 3]), - tip, - iced::widget::tooltip::Position::Bottom, - colors, - ) -} 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 6e1e610..5689334 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 @@ -147,6 +147,11 @@ pub const TOOL_SLOTS: &[ToolSlot] = &[ label: "Retouch", icon: "icons/spot.svg", }, + ToolSlot { + tools: &[Tool::CustomShape(0)], + label: "Custom Shapes", + icon: "icons/star.svg", + }, ]; /// Build the Photopea-style sidebar element. @@ -158,17 +163,15 @@ pub fn view<'a>( fg_color: &'a [u8; 4], bg_color: &'a [u8; 4], colors: ThemeColors, - active_slot: usize, - sub_tools_open: bool, + open_subtool_slot: Option, slot_last_used: &'a [usize], expanded: bool, ) -> Element<'a, Message> { let sidebar_width = if expanded { 72 } else { 36 }; log::info!( - "[sidebar] expanded={} active_slot={} sub_tools_open={}", + "[sidebar] expanded={} open_subtool_slot={:?}", expanded, - active_slot, - sub_tools_open + open_subtool_slot ); // Tool buttons — single column or 2-column grid depending on expanded state @@ -184,12 +187,11 @@ pub fn view<'a>( tool_state, colors, idx, - active_slot, - sub_tools_open, + open_subtool_slot, slot_last_used, ); - let popup_row = sub_tools_open && active_slot == idx && slot.tools.len() > 1; + let popup_row = open_subtool_slot == Some(idx) && slot.tools.len() > 1; if popup_row { if row_items > 0 { tool_list = tool_list.push(row_children); @@ -219,8 +221,7 @@ pub fn view<'a>( tool_state, colors, idx, - active_slot, - sub_tools_open, + open_subtool_slot, slot_last_used, ); tool_list = tool_list.push(btn); @@ -434,8 +435,7 @@ fn tool_button<'a>( tool_state: &ToolState, colors: ThemeColors, slot_idx: usize, - active_slot: usize, - sub_tools_open: bool, + open_subtool_slot: Option, slot_last_used: &[usize], ) -> Element<'a, Message> { let is_active = slot.tools.contains(&tool_state.active_tool); @@ -484,7 +484,7 @@ fn tool_button<'a>( // If slot has sub-tools and is active, show sub-tools popup let has_sub_tools = slot.tools.len() > 1; - let show_popup = has_sub_tools && sub_tools_open && active_slot == slot_idx; + let show_popup = has_sub_tools && open_subtool_slot == Some(slot_idx); let primary_tool = represented_tool; let active = is_active; @@ -701,8 +701,8 @@ fn tool_button<'a>( .padding(1) .width(68) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_panel)), - border: iced::Border::default().color(colors.border_high).width(1), + background: Some(iced::Background::Color(colors.bg_active)), + border: iced::Border::default().color(colors.accent).width(1), shadow: iced::Shadow { color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4), offset: iced::Vector::new(1.0, 0.0), 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 index b35fc1d..ad03ebe 100644 --- 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 @@ -1,254 +1,371 @@ -//! A keyboard-editable numeric slider widget for iced. +//! Theme-aware progress slider with hover-activated numeric entry. //! -//! 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. +//! **Purpose:** Provides one compact numeric control for every Iced panel, matching the egui +//! `PlainSlider` interaction model without relying on the deprecated Iced `Component` API. +//! **Logic & Workflow:** An Iced Canvas retains transient drag/edit state, maps pointer positions +//! to stepped values, captures numeric keyboard input while hovered, and draws a recessed track, +//! gradient value fill, spinner arrows, centered text, and hover elevation. +//! **Side Effects / Dependencies:** Emits caller-provided messages; all state remains local to the +//! widget tree and all colors derive from `ThemeColors`. -#![allow(deprecated)] - -use iced::widget::{column, component, container, mouse_area, row, slider, text, text_input, Component}; -use iced::{Element, Length, Renderer, Theme}; +use crate::theme::ThemeColors; +use iced::keyboard::{self, key::Named, Key}; +use iced::mouse; +use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke}; +use iced::{alignment, border, Element, Length, Pixels, Point, Rectangle, Size}; use std::sync::Arc; -/// Internal state for a `PlainSlider` instance. -#[derive(Debug, Default, Clone)] +const CONTROL_HEIGHT: f32 = 22.0; +const SPINNER_WIDTH: f32 = 24.0; +const VALUE_EDIT_WIDTH: f32 = 72.0; + +/// Persistent interaction state for one slider instance. +#[derive(Debug, Default)] pub(crate) struct State { - /// Whether the user is currently typing a value. + dragging: bool, 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. +/// Canvas program describing one plain slider. #[derive(Clone)] -pub(crate) struct PlainSlider { - /// Human-readable label shown to the left of the value. +struct PlainSlider { 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)] + colors: ThemeColors, 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") + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .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() + .finish_non_exhaustive() } } 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. + /// Formats the current value for centered display. 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) + let displayed = if self.suffix == "%" { + value * 100.0 } else { - format!("{:.*} {}", self.decimals, value, self.suffix) + value + }; + let value = format!("{:.*}", self.decimals, displayed); + if self.suffix.is_empty() { + format!("{}: {}", self.label, value) + } else if self.suffix == "%" { + format!("{}: {}%", self.label, value) + } else { + format!("{}: {} {}", self.label, value, self.suffix) } } - /// Parses a raw string back into a numeric value, taking `%` into account. + /// Parses an edit buffer, normalizes percentages, and clamps to the slider range. fn parse_value(&self, raw: &str) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - let numeric = trimmed + let numeric = raw + .trim() .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); - - // The slider is wrapped in a fill container so the thumb track uses the - // full available width from the parent panel, not just its intrinsic size. - let slider_control = container( - slider(self.range.clone(), self.value, Event::SliderChanged) - .step(self.step) - .width(Length::Fill), - ) - .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) - .width(Length::Fill); - - column![ - row![ - text(&self.label).size(12).width(Length::Shrink), - editor, - ] - .spacing(4) - .width(Length::Fill) - .align_y(iced::Alignment::Center), - slider_control.width(Length::Fill), - ] - .spacing(2) - .into() + let parsed = numeric.parse::().ok()?; + let value = if self.suffix == "%" { + parsed / 100.0 } 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); + parsed + }; + Some(self.snap(value)) + } - column![ - row![ - text(&self.label).size(12).width(Length::Shrink), - value_label, - ] - .spacing(4) - .width(Length::Fill) - .align_y(iced::Alignment::Center), - slider_control.width(Length::Fill), - ] - .spacing(2) - .into() + /// Snaps a value to the configured step relative to the range minimum. + fn snap(&self, value: f32) -> f32 { + let min = *self.range.start(); + let max = *self.range.end(); + if self.step > f32::EPSILON { + (min + ((value - min) / self.step).round() * self.step).clamp(min, max) + } else { + value.clamp(min, max) + } + } + + /// Maps a horizontal cursor coordinate to a stepped value. + fn value_at(&self, x: f32, width: f32) -> f32 { + let track_width = (width - SPINNER_WIDTH).max(1.0); + let fraction = (x / track_width).clamp(0.0, 1.0); + self.snap(*self.range.start() + (*self.range.end() - *self.range.start()) * fraction) + } + + /// Returns the normalized fill fraction for the current value. + fn fraction(&self) -> f32 { + let span = *self.range.end() - *self.range.start(); + if span.abs() <= f32::EPSILON { + 0.0 + } else { + ((self.value - *self.range.start()) / span).clamp(0.0, 1.0) + } + } + + /// Starts direct numeric editing with one typed character. + fn begin_typing(state: &mut State, text: &str) -> bool { + if is_numeric_fragment(text) { + state.editing = true; + state.buffer.clear(); + state.buffer.push_str(text); + true + } else { + false } } } -/// Convenience constructor that wraps `PlainSlider` in a lazy component element. +impl canvas::Program for PlainSlider { + type State = State; + + fn draw( + &self, + state: &Self::State, + renderer: &iced::Renderer, + _theme: &iced::Theme, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let hovered = cursor.is_over(bounds); + let radius = border::Radius::from(4.0); + let outer = Path::rounded_rectangle(Point::ORIGIN, bounds.size(), radius); + + if hovered { + for expansion in [3.0_f32, 2.0, 1.0] { + let mut shadow = self.colors.accent; + shadow.a = 0.035 * (4.0 - expansion); + let path = Path::rounded_rectangle( + Point::new(-expansion, -expansion), + Size::new( + bounds.width + expansion * 2.0, + bounds.height + expansion * 2.0, + ), + border::Radius::from(4.0 + expansion), + ); + frame.stroke(&path, Stroke::default().with_color(shadow).with_width(1.0)); + } + } + + frame.fill(&outer, self.colors.recessed()); + + let track_width = (bounds.width - SPINNER_WIDTH).max(0.0); + let fill_width = track_width * self.fraction(); + if fill_width > 0.0 { + let bands = 4; + for band in 0..bands { + let t = band as f32 / (bands - 1) as f32; + let mut fill = mix_color(self.colors.accent, self.colors.accent_hover, t * 0.55); + fill.a = if self.colors.is_light { 0.30 } else { 0.25 }; + frame.fill_rectangle( + Point::new(0.0, bounds.height * band as f32 / bands as f32), + Size::new(fill_width, bounds.height / bands as f32 + 0.5), + fill, + ); + } + frame.fill_rectangle( + Point::new((fill_width - 1.5).max(0.0), 1.0), + Size::new(3.0_f32.min(fill_width), (bounds.height - 2.0).max(0.0)), + self.colors.accent, + ); + } + + frame.stroke( + &outer, + Stroke::default() + .with_color(if hovered { + self.colors.accent + } else { + self.colors.border_high + }) + .with_width(1.0), + ); + frame.stroke( + &Path::rectangle( + Point::new(track_width, 1.0), + Size::new(1.0, (bounds.height - 2.0).max(0.0)), + ), + Stroke::default() + .with_color(self.colors.border_low) + .with_width(1.0), + ); + + let center_x = track_width / 2.0; + let display = if state.editing { + format!("{}|", state.buffer) + } else { + self.format_value(self.value) + }; + frame.fill_text(canvas::Text { + content: display, + position: Point::new(center_x, bounds.height / 2.0), + color: self.colors.text_primary, + size: Pixels(12.0), + horizontal_alignment: alignment::Horizontal::Center, + vertical_alignment: alignment::Vertical::Center, + ..Default::default() + }); + + let spinner_x = track_width + SPINNER_WIDTH / 2.0; + for (center_y, upward) in [(6.5, true), (15.5, false)] { + let path = Path::new(|builder| { + if upward { + builder.move_to(Point::new(spinner_x, center_y - 2.5)); + builder.line_to(Point::new(spinner_x - 3.0, center_y + 2.0)); + builder.line_to(Point::new(spinner_x + 3.0, center_y + 2.0)); + } else { + builder.move_to(Point::new(spinner_x, center_y + 2.5)); + builder.line_to(Point::new(spinner_x - 3.0, center_y - 2.0)); + builder.line_to(Point::new(spinner_x + 3.0, center_y - 2.0)); + } + builder.close(); + }); + frame.fill(&path, self.colors.text_secondary); + } + + vec![frame.into_geometry()] + } + + fn update( + &self, + state: &mut Self::State, + event: canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> (canvas::event::Status, Option) { + let local = cursor.position_in(bounds); + + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + let Some(position) = local else { + if state.editing { + state.editing = false; + state.buffer.clear(); + } + return (canvas::event::Status::Ignored, None); + }; + + if position.x >= bounds.width - SPINNER_WIDTH { + let direction = if position.y < bounds.height / 2.0 { + 1.0 + } else { + -1.0 + }; + let value = self.snap(self.value + self.step.max(f32::EPSILON) * direction); + return ( + canvas::event::Status::Captured, + Some((self.on_change)(value)), + ); + } + + let edit_left = (bounds.width - SPINNER_WIDTH - VALUE_EDIT_WIDTH) / 2.0; + let edit_right = edit_left + VALUE_EDIT_WIDTH; + if position.x >= edit_left && position.x <= edit_right { + state.editing = true; + state.buffer = if self.suffix == "%" { + format!("{:.*}", self.decimals, self.value * 100.0) + } else { + format!("{:.*}", self.decimals, self.value) + }; + return (canvas::event::Status::Captured, None); + } + + state.dragging = true; + let value = self.value_at(position.x, bounds.width); + ( + canvas::event::Status::Captured, + Some((self.on_change)(value)), + ) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { + if let Some(position) = local { + let value = self.value_at(position.x, bounds.width); + return ( + canvas::event::Status::Captured, + Some((self.on_change)(value)), + ); + } + (canvas::event::Status::Captured, None) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if state.dragging { + state.dragging = false; + return (canvas::event::Status::Captured, None); + } + (canvas::event::Status::Ignored, None) + } + canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, text, .. }) + if local.is_some() || state.editing => + { + if state.editing { + match key { + Key::Named(Named::Enter) => { + let value = self.parse_value(&state.buffer); + state.editing = false; + state.buffer.clear(); + return ( + canvas::event::Status::Captured, + value.map(|value| (self.on_change)(value)), + ); + } + Key::Named(Named::Escape) => { + state.editing = false; + state.buffer.clear(); + return (canvas::event::Status::Captured, None); + } + Key::Named(Named::Backspace) => { + state.buffer.pop(); + return (canvas::event::Status::Captured, None); + } + _ => {} + } + if let Some(text) = text.filter(|text| is_numeric_fragment(text)) { + state.buffer.push_str(&text); + return (canvas::event::Status::Captured, None); + } + } else if let Some(text) = text.filter(|text| is_numeric_fragment(text)) { + PlainSlider::::begin_typing(state, &text); + return (canvas::event::Status::Captured, None); + } + (canvas::event::Status::Ignored, None) + } + _ => (canvas::event::Status::Ignored, None), + } + } + + fn mouse_interaction( + &self, + _state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} + +/// Builds a theme-aware slider element. /// -/// Use this from panel code in place of the standard `slider` widget. +/// **Arguments:** Label/value/range define the numeric control; `step`, `suffix`, and `decimals` +/// control interaction and formatting; `colors` is the active palette; `on_change` creates the +/// parent message. **Returns:** A fill-width 22 px slider. **Side Effects:** None until interaction. pub fn plain_slider( label: impl Into, value: f32, @@ -256,87 +373,87 @@ pub fn plain_slider( step: f32, suffix: impl Into, decimals: usize, + colors: ThemeColors, on_change: impl Fn(f32) -> Message + 'static, ) -> Element<'static, Message> where - Message: 'static + Clone, + Message: 'static, { - component(PlainSlider::new(label, value, range, step, suffix, decimals, on_change)) + Canvas::new(PlainSlider { + label: label.into(), + value, + range, + step, + suffix: suffix.into(), + decimals, + colors, + on_change: Arc::new(on_change), + }) + .width(Length::Fill) + .height(Length::Fixed(CONTROL_HEIGHT)) + .into() +} + +/// Returns whether text can be appended to a numeric edit buffer. +fn is_numeric_fragment(text: &str) -> bool { + !text.is_empty() + && text + .chars() + .all(|character| character.is_ascii_digit() || matches!(character, '.' | ',' | '-')) +} + +/// Blends two theme colors while preserving alpha interpolation. +fn mix_color(from: iced::Color, to: iced::Color, amount: f32) -> iced::Color { + let amount = amount.clamp(0.0, 1.0); + iced::Color::new( + from.r + (to.r - from.r) * amount, + from.g + (to.g - from.g) * amount, + from.b + (to.b - from.b) * amount, + from.a + (to.a - from.a) * amount, + ) } #[cfg(test)] mod tests { - use super::PlainSlider; - use iced::widget::Component; + use super::{is_numeric_fragment, PlainSlider, State}; + use crate::theme::{ThemeColors, ThemePreset}; - 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%"); + fn test_slider(suffix: &str) -> PlainSlider<()> { + PlainSlider { + label: "Opacity".to_string(), + value: 0.5, + range: 0.0..=1.0, + step: 0.01, + suffix: suffix.to_string(), + decimals: 0, + colors: ThemeColors::get(ThemePreset::Photopea), + on_change: std::sync::Arc::new(|_| ()), + } } #[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); + fn percentage_format_parse_and_clamp_are_stable() { + let slider = test_slider("%"); + assert_eq!(slider.format_value(0.5), "Opacity: 50%"); + assert_eq!(slider.parse_value("75"), Some(0.75)); 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); + fn hover_typing_accepts_only_numeric_fragments() { + let mut state = State::default(); + assert!(PlainSlider::<()>::begin_typing(&mut state, "-")); + assert!(state.editing); + assert_eq!(state.buffer, "-"); + assert!(is_numeric_fragment("12.5")); + assert!(!is_numeric_fragment("px")); } #[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); + fn pointer_mapping_respects_endpoints_and_step() { + let slider = test_slider(""); + assert_eq!(slider.value_at(0.0, 124.0), 0.0); + assert_eq!(slider.value_at(100.0, 124.0), 1.0); + assert!((slider.value_at(33.0, 124.0) - 0.33).abs() < 1e-6); } } diff --git a/hcie-iced-app/crates/iced-panel-adapter/Cargo.toml b/hcie-iced-app/crates/iced-panel-adapter/Cargo.toml index 007e1ec..d1ba4cc 100644 --- a/hcie-iced-app/crates/iced-panel-adapter/Cargo.toml +++ b/hcie-iced-app/crates/iced-panel-adapter/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "iced-panel-adapter" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [lib] crate-type = ["rlib"] diff --git a/hcie-iced-app/crates/iced-panel-adapter/src/lib.rs b/hcie-iced-app/crates/iced-panel-adapter/src/lib.rs index a884c04..975d867 100644 --- a/hcie-iced-app/crates/iced-panel-adapter/src/lib.rs +++ b/hcie-iced-app/crates/iced-panel-adapter/src/lib.rs @@ -7,9 +7,11 @@ pub mod theme; pub use theme::{ThemeColors, ThemePreset}; +#[path = "../../hcie-iced-gui/src/widgets/plain_slider.rs"] +pub mod plain_slider; use hcie_engine_api::{ModulePanel, WidgetDescription}; -use iced::widget::{button, checkbox, horizontal_rule, pick_list, slider, text}; +use iced::widget::{button, checkbox, horizontal_rule, pick_list, text}; use iced::{Element, Length}; /// Message type for panel widget interactions. @@ -20,20 +22,20 @@ pub enum PanelMessage { } /// Renders a complete `ModulePanel` as an Iced element tree. -pub fn render_module_panel(panel: &ModulePanel) -> Element<'_, PanelMessage> { +pub fn render_module_panel(panel: &ModulePanel, colors: ThemeColors) -> Element<'_, PanelMessage> { let mut col = iced::widget::Column::new().spacing(8); col = col.push(text(&panel.title).size(16).font(iced::Font::MONOSPACE)); for widget in &panel.widgets { - col = col.push(render_widget(widget)); + col = col.push(render_widget(widget, colors)); } col.into() } /// Renders a single `WidgetDescription` as an Iced element. -pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> { +pub fn render_widget(widget: &WidgetDescription, colors: ThemeColors) -> Element<'_, PanelMessage> { match widget { WidgetDescription::Label { text: label_text, .. @@ -56,17 +58,18 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> { let id = id.clone(); let current = *value; - let slider_widget = slider(*min..=*max, current, move |v| { - PanelMessage::ValueChanged(id.clone(), serde_json::Value::from(v)) - }) - .step(0.01) - .width(Length::Fill); - - iced::widget::Column::new() - .push(text(format!("{}: {:.2}", label, current)).size(12)) - .push(slider_widget) - .spacing(4) - .into() + plain_slider::plain_slider( + label.clone(), + current, + *min..=*max, + 0.01, + "", + 2, + colors, + move |new_value| { + PanelMessage::ValueChanged(id.clone(), serde_json::Value::from(new_value)) + }, + ) } WidgetDescription::ColorPicker { label, rgba, .. } => { let r = rgba[0] as f32 / 255.0; @@ -125,7 +128,7 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> { .spacing(4) .padding(iced::Padding::ZERO.left(12)); for child in children { - inner = inner.push(render_widget(child)); + inner = inner.push(render_widget(child, colors)); } iced::widget::Column::new() diff --git a/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs b/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs index b352927..887c57a 100644 --- a/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs +++ b/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs @@ -213,4 +213,55 @@ impl ThemeColors { pub fn current() -> Self { Self::get(ThemePreset::default()) } + + /// Returns the recessed surface used by inputs and slider tracks. + /// + /// **Logic & Workflow:** Blends the panel surface toward black in dark themes and toward a + /// slightly darker neutral in light themes. **Arguments/Returns:** Takes the current palette + /// and returns an opaque derived color. **Side Effects:** None. + pub fn recessed(self) -> Color { + blend( + self.bg_panel, + Color::BLACK, + if self.is_light { 0.08 } else { 0.28 }, + ) + } + + /// Returns the subtly raised surface used by cards, headers, and popup content. + /// + /// **Logic & Workflow:** Blends the panel surface toward white by a restrained amount so the + /// elevation remains compatible with every preset. **Side Effects:** None. + pub fn elevated(self) -> Color { + blend( + self.bg_panel, + Color::WHITE, + if self.is_light { 0.035 } else { 0.075 }, + ) + } + + /// Returns the highlight edge color for soft raised controls. + pub fn surface_highlight(self) -> Color { + let mut color = if self.is_light { + Color::WHITE + } else { + self.text_primary + }; + color.a = if self.is_light { 0.45 } else { 0.11 }; + color + } + + /// Returns the shared shadow color for elevated GUI surfaces. + pub fn surface_shadow(self) -> Color { + Color::from_rgba(0.0, 0.0, 0.0, if self.is_light { 0.18 } else { 0.38 }) + } +} + +/// Linearly blends two colors while preserving an opaque result. +fn blend(from: Color, to: Color, amount: f32) -> Color { + let amount = amount.clamp(0.0, 1.0); + Color::from_rgb( + from.r + (to.r - from.r) * amount, + from.g + (to.g - from.g) * amount, + from.b + (to.b - from.b) * amount, + ) } diff --git a/hcie-iced-app/crates/iced-panel-ai-chat/Cargo.toml b/hcie-iced-app/crates/iced-panel-ai-chat/Cargo.toml index d3a0f35..8dbefb4 100644 --- a/hcie-iced-app/crates/iced-panel-ai-chat/Cargo.toml +++ b/hcie-iced-app/crates/iced-panel-ai-chat/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "iced-panel-ai-chat" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [lib] crate-type = ["rlib"] diff --git a/hcie-iced-app/crates/iced-panel-script/Cargo.toml b/hcie-iced-app/crates/iced-panel-script/Cargo.toml index a8be9c2..14abb26 100644 --- a/hcie-iced-app/crates/iced-panel-script/Cargo.toml +++ b/hcie-iced-app/crates/iced-panel-script/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "iced-panel-script" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [lib] crate-type = ["rlib"] diff --git a/hcie-qt-app/build/CMakeFiles/4.2.3/CMakeDetermineCompilerABI_CXX.bin b/hcie-qt-app/build/CMakeFiles/4.2.3/CMakeDetermineCompilerABI_CXX.bin old mode 100755 new mode 100644 diff --git a/hcie-qt-app/build/CMakeFiles/4.2.3/CompilerIdCXX/a.out b/hcie-qt-app/build/CMakeFiles/4.2.3/CompilerIdCXX/a.out old mode 100755 new mode 100644 diff --git a/hcie-qt-app/build/hcie-qt b/hcie-qt-app/build/hcie-qt old mode 100755 new mode 100644 diff --git a/line_count_report.txt b/line_count_report.txt index d4cf435..d9fcb39 100644 --- a/line_count_report.txt +++ b/line_count_report.txt @@ -2,7 +2,7 @@ SATIR SAYISI RAPORU Proje: HCIE-Rust v4 Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05 -Tarih: 2026-07-19 20:01:21 +Tarih: 2026-07-21 03:17:11 ========================================= ----------------------------------------- @@ -13,28 +13,28 @@ Tarih: 2026-07-19 20:01:21 hcie-brush-engine 4845 satır hcie-color 85 satır hcie-composite 1456 satır - hcie-document 666 satır + hcie-document 761 satır hcie-draw 619 satır - hcie-egui-app 37419 satır - hcie-engine-api 8360 satır + hcie-egui-app 37437 satır + hcie-engine-api 8364 satır hcie-engine-api-orig 3874 satır hcie-filter 3238 satır hcie-fx 5535 satır - hcie-history 100 satır - hcie-iced-app 36049 satır - hcie-io 12121 satır - hcie-kra 1393 satır + hcie-history 139 satır + hcie-iced-app 38703 satır + hcie-io 12122 satır + hcie-kra 1398 satır hcie-native 153 satır - hcie-protocol 3678 satır + hcie-protocol 3756 satır hcie-psd 5120 satır hcie-psd-saver 1277 satır hcie-selection 392 satır hcie-text 1010 satır hcie-tile 361 satır - hcie-vector 2853 satır + hcie-vector 2921 satır hcie-vision 3131 satır - TOPLAM RUST: 136667 satır + TOPLAM RUST: 139629 satır ----------------------------------------- C++ / HEADER (.cpp, .h) @@ -102,14 +102,14 @@ Tarih: 2026-07-19 20:01:21 MARKDOWN (.md) -------------------------------- - (kök dizin) 17154 satır + (kök dizin) 17486 satır - TOPLAM MARKDOWN: 17154 satır + TOPLAM MARKDOWN: 17486 satır ========================================= KOD SATIRLARI TOPLAMI (GRAND TOTAL) ========================================= - Rust: 136667 satır + Rust: 139629 satır C++ / Header: 5031 satır JavaScript: 0 satır Svelte: 0 satır @@ -119,9 +119,9 @@ Tarih: 2026-07-19 20:01:21 CSS: 0 satır Shell Script: 1445 satır ----------------------------------------- - KOD TOPLAMI: 145410 satır + KOD TOPLAMI: 148372 satır - RUST + C++/H TOPLAMI: 141698 satır + RUST + C++/H TOPLAMI: 144660 satır (JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir) ========================================= diff --git a/scripts/cargo-with-build-id.sh b/scripts/cargo-with-build-id.sh new file mode 100755 index 0000000..b19771e --- /dev/null +++ b/scripts/cargo-with-build-id.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Runs one Cargo command with one synchronized HCIE build-number increment. + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +LOCK_DIR="${ROOT_DIR}/.build-id.lock" +COUNTER_FILE="${ROOT_DIR}/build.id" + +cleanup() { + rm -rf -- "${LOCK_DIR}" +} + +acquire_lock() { + while ! mkdir -- "${LOCK_DIR}" 2>/dev/null; do + if [[ -f "${LOCK_DIR}/pid" ]]; then + local owner + owner="$(<"${LOCK_DIR}/pid")" + if [[ "${owner}" =~ ^[0-9]+$ ]] && ! kill -0 "${owner}" 2>/dev/null; then + rm -rf -- "${LOCK_DIR}" + continue + fi + fi + sleep 0.05 + done + printf '%s\n' "$$" > "${LOCK_DIR}/pid" + trap cleanup EXIT INT TERM +} + +write_counter() { + local target="$1" + local value="$2" + local temporary="${target}.tmp.$$" + printf '%s\n' "${value}" > "${temporary}" + mv -f -- "${temporary}" "${target}" +} + +if [[ $# -eq 0 ]]; then + echo "usage: scripts/cargo-with-build-id.sh [arguments...]" >&2 + exit 2 +fi + +acquire_lock + +if [[ -f "${COUNTER_FILE}" ]]; then + current="$(tr -d '[:space:]' < "${COUNTER_FILE}")" +else + current="0" +fi +if [[ ! "${current}" =~ ^[0-9]+$ ]]; then + echo "build.id must contain one unsigned integer, found: ${current}" >&2 + exit 1 +fi +if (( current >= 9223372036854775807 )); then + echo "build.id reached the supported integer limit" >&2 + exit 1 +fi + +next=$((current + 1)) +write_counter "${COUNTER_FILE}" "${next}" +write_counter "${ROOT_DIR}/hcie-egui-app/build.id" "${next}" +write_counter "${ROOT_DIR}/hcie-egui-app/crates/hcie-gui-egui/build.id" "${next}" + +export HCIE_BUILD_ID_OVERRIDE="${next}" +echo "HCIE build ${next}: cargo $*" + +cd -- "${ROOT_DIR}" +cargo "$@"