BIG REFACTOR GPT SOL

feat: Refactor Iced panel adapter and introduce build metadata management

- Updated Cargo.toml files across multiple crates to use workspace versioning.
- Enhanced the `iced-panel-adapter` to include a new `plain_slider` module and updated widget rendering to support theme colors.
- Added new theme color utilities for recessed and elevated surfaces in `iced-panel-adapter`.
- Introduced a new `hcie-build-info` crate to manage build metadata, including a build ID system.
- Created a build script to synchronize build IDs across the workspace.
- Added a Makefile for simplified build commands for the Iced application.
- Implemented regression tests for vector shape creation history in the engine API.
- Added a new script for managing Cargo commands with synchronized build ID increments.
- Updated line count report to reflect recent changes in codebase.
- Created a visual plan document for future improvements in the Iced history and panel systems.
This commit is contained in:
2026-07-21 04:25:23 +03:00
parent 325bd0d8e8
commit b49106dc1d
60 changed files with 2716 additions and 1266 deletions
@@ -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 7284 px, 46 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 eguis 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, 26 px shadow blur, and 26 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 references 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.
Generated
+6
View File
@@ -2576,6 +2576,10 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "hcie-build-info"
version = "0.1.0"
[[package]] [[package]]
name = "hcie-color" name = "hcie-color"
version = "0.1.0" version = "0.1.0"
@@ -2712,6 +2716,7 @@ dependencies = [
"egui_extras", "egui_extras",
"env_logger", "env_logger",
"evdev", "evdev",
"hcie-build-info",
"hcie-engine-api", "hcie-engine-api",
"image 0.25.10", "image 0.25.10",
"log", "log",
@@ -2749,6 +2754,7 @@ dependencies = [
"env_logger", "env_logger",
"evdev", "evdev",
"futures-util", "futures-util",
"hcie-build-info",
"hcie-engine-api", "hcie-engine-api",
"iced", "iced",
"iced-panel-adapter", "iced-panel-adapter",
+6
View File
@@ -1,6 +1,7 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = [
"hcie-build-info",
"hcie-protocol", "hcie-protocol",
"hcie-color", "hcie-color",
"hcie-blend", "hcie-blend",
@@ -37,7 +38,12 @@ members = [
"tools/panel-tuner", "tools/panel-tuner",
] ]
[workspace.package]
version = "0.1.0"
edition = "2021"
[workspace.dependencies] [workspace.dependencies]
hcie-build-info = { path = "hcie-build-info" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
Executable
+12
View File
@@ -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 "$@"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "hcie-build-info"
version.workspace = true
edition.workspace = true
build = "build.rs"
[lib]
crate-type = ["rlib"]
+58
View File
@@ -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::<u64>()
.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::<u64>().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}"
);
}
+35
View File
@@ -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}")));
}
}
+139 -44
View File
@@ -1,6 +1,9 @@
#![allow(dead_code)] #![allow(dead_code)]
use hcie_protocol::{BlendMode, Layer, LayerData, LayerInfo, LayerStyle, LayerType, MAX_UNDO_STEPS_DEFAULT, VectorShape};
use hcie_history::{HistoryManager, UndoableAction}; 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}; use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_LAYER_ID: AtomicU64 = AtomicU64::new(1); static NEXT_LAYER_ID: AtomicU64 = AtomicU64::new(1);
@@ -66,7 +69,8 @@ impl Document {
pub fn add_layer(&mut self, name: impl Into<String>) -> u64 { pub fn add_layer(&mut self, name: impl Into<String>) -> u64 {
let name_str = name.into(); 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 new_id = next_layer_id();
let idx = self.layers.len(); let idx = self.layers.len();
self.layers.push(new_layer); self.layers.push(new_layer);
@@ -87,8 +91,53 @@ impl Document {
new_id 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<String>,
shape: VectorShape,
description: impl Into<String>,
) -> 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) { 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 layer = &self.layers[idx];
let action = LayerDeleteAction { let action = LayerDeleteAction {
@@ -128,9 +177,13 @@ impl Document {
} }
pub fn set_layer_visible(&mut self, idx: usize, visible: bool) { 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; 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].visible = visible;
self.layers[idx].dirty = true; self.layers[idx].dirty = true;
@@ -155,11 +208,13 @@ impl Document {
pub fn undo(&mut self) { pub fn undo(&mut self) {
self.history.undo(&mut self.layers); self.history.undo(&mut self.layers);
self.normalize_active_layer();
self.composite_dirty = true; self.composite_dirty = true;
} }
pub fn redo(&mut self) { pub fn redo(&mut self) {
self.history.redo(&mut self.layers); self.history.redo(&mut self.layers);
self.normalize_active_layer();
self.composite_dirty = true; self.composite_dirty = true;
} }
@@ -185,16 +240,29 @@ impl Document {
pub fn jump_to_history(&mut self, idx: i32) { pub fn jump_to_history(&mut self, idx: i32) {
self.history.jump_to(idx, &mut self.layers); self.history.jump_to(idx, &mut self.layers);
self.normalize_active_layer();
self.composite_dirty = true; 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( pub fn push_vector_snapshot(
&mut self, &mut self,
layer_idx: usize, layer_idx: usize,
before_shapes: Option<Vec<VectorShape>>, before_shapes: Option<Vec<VectorShape>>,
description: String, description: String,
) { ) {
if layer_idx >= self.layers.len() { return; } if layer_idx >= self.layers.len() {
return;
}
let layer = &self.layers[layer_idx]; let layer = &self.layers[layer_idx];
let after_shapes = if let LayerData::Vector { shapes } = &layer.data { let after_shapes = if let LayerData::Vector { shapes } = &layer.data {
Some(shapes.clone()) Some(shapes.clone())
@@ -343,7 +411,9 @@ impl Document {
} }
pub fn move_layer(&mut self, from: usize, to: usize) { 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 layer = self.layers.remove(from);
let insert_idx = if to > from { to } else { to }; let insert_idx = if to > from { to } else { to };
self.layers.insert(insert_idx, layer); self.layers.insert(insert_idx, layer);
@@ -356,7 +426,9 @@ impl Document {
let y1 = y.min(self.canvas_height); let y1 = y.min(self.canvas_height);
let w1 = w.min(self.canvas_width - x1); let w1 = w.min(self.canvas_width - x1);
let h1 = h.min(self.canvas_height - y1); 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 { for layer in &mut self.layers {
let mut new_pixels = vec![0u8; (w1 * h1 * 4) as usize]; 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 { if src_x < layer.width && src_y < layer.height {
let src_i = ((src_y * layer.width + src_x) * 4) as usize; let src_i = ((src_y * layer.width + src_x) * 4) as usize;
let dst_i = ((ly * w1 + lx) * 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) { 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 { for layer in &mut self.layers {
let mut new_pixels = vec![0u8; (w * h * 4) as usize]; let mut new_pixels = vec![0u8; (w * h * 4) as usize];
let copy_w = w.min(layer.width); let copy_w = w.min(layer.width);
@@ -392,9 +467,8 @@ impl Document {
let src_start = (y * layer.width) as usize * 4; let src_start = (y * layer.width) as usize * 4;
let dst_start = (y * w) as usize * 4; let dst_start = (y * w) as usize * 4;
let row_bytes = (copy_w * 4) as usize; let row_bytes = (copy_w * 4) as usize;
new_pixels[dst_start..dst_start + row_bytes].copy_from_slice( new_pixels[dst_start..dst_start + row_bytes]
&layer.pixels[src_start..src_start + row_bytes] .copy_from_slice(&layer.pixels[src_start..src_start + row_bytes]);
);
} }
layer.pixels = new_pixels; layer.pixels = new_pixels;
layer.width = w; layer.width = w;
@@ -441,7 +515,9 @@ impl Document {
pub fn invert_selection(&mut self) { pub fn invert_selection(&mut self) {
if let Some(mask) = &mut self.selection_mask { 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 { } else {
self.select_all(); self.select_all();
} }
@@ -449,7 +525,10 @@ impl Document {
pub fn get_mask_at(&self, x: u32, y: u32) -> u8 { pub fn get_mask_at(&self, x: u32, y: u32) -> u8 {
match &self.selection_mask { 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, None => 255,
} }
} }
@@ -477,32 +556,34 @@ impl Document {
}) })
} }
pub fn all_layer_ids(&self) -> Vec<u64> { pub fn all_layer_ids(&self) -> Vec<u64> {
self.layers.iter().map(|l| l.id).collect() self.layers.iter().map(|l| l.id).collect()
} }
pub fn layer_infos(&self) -> Vec<LayerInfo> { pub fn layer_infos(&self) -> Vec<LayerInfo> {
self.layers.iter().map(|l| LayerInfo { self.layers
id: l.id, .iter()
name: l.name.clone(), .map(|l| LayerInfo {
layer_type: l.layer_type, id: l.id,
width: l.width, name: l.name.clone(),
height: l.height, layer_type: l.layer_type,
visible: l.visible, width: l.width,
opacity: l.opacity, height: l.height,
blend_mode: l.blend_mode, visible: l.visible,
locked: l.locked, opacity: l.opacity,
parent_id: l.parent_id, blend_mode: l.blend_mode,
clipping_mask: l.clipping_mask, locked: l.locked,
collapsed: l.collapsed, parent_id: l.parent_id,
has_vector_shapes: matches!(&l.data, LayerData::Vector { .. }), clipping_mask: l.clipping_mask,
shape_count: match &l.data { collapsed: l.collapsed,
LayerData::Vector { shapes } => shapes.len(), has_vector_shapes: matches!(&l.data, LayerData::Vector { .. }),
_ => 0, shape_count: match &l.data {
}, LayerData::Vector { shapes } => shapes.len(),
has_effects: !l.effects.is_empty() || !l.styles.is_empty(), _ => 0,
}).collect() },
has_effects: !l.effects.is_empty() || !l.styles.is_empty(),
})
.collect()
} }
} }
@@ -551,7 +632,12 @@ struct LayerDeleteAction {
impl UndoableAction<Vec<Layer>> for LayerDeleteAction { impl UndoableAction<Vec<Layer>> for LayerDeleteAction {
fn undo(&mut self, layers: &mut Vec<Layer>) { fn undo(&mut self, layers: &mut Vec<Layer>) {
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.layer_type = self.layer_type;
restored.opacity = self.opacity; restored.opacity = self.opacity;
restored.blend_mode = self.blend_mode; restored.blend_mode = self.blend_mode;
@@ -599,8 +685,11 @@ impl UndoableAction<Vec<Layer>> for DrawAction {
let src_start = (row * rw * 4) as usize; let src_start = (row * rw * 4) as usize;
let dst_start = (((y0 + row) * dst_w + x0) * 4) as usize; let dst_start = (((y0 + row) * dst_w + x0) * 4) as usize;
let len = (rw * 4) as usize; let len = (rw * 4) as usize;
if src_start + len <= self.before_pixels.len() && dst_start + len <= layer.pixels.len() { if src_start + len <= self.before_pixels.len()
layer.pixels[dst_start..dst_start + len].copy_from_slice(&self.before_pixels[src_start..src_start + 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 { } else {
@@ -608,7 +697,9 @@ impl UndoableAction<Vec<Layer>> for DrawAction {
} }
layer.dirty = true; layer.dirty = true;
if let Some(ref shapes) = self.before_shapes { 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<Vec<Layer>> for DrawAction {
let src_start = (row * rw * 4) as usize; let src_start = (row * rw * 4) as usize;
let dst_start = (((y0 + row) * dst_w + x0) * 4) as usize; let dst_start = (((y0 + row) * dst_w + x0) * 4) as usize;
let len = (rw * 4) as usize; let len = (rw * 4) as usize;
if src_start + len <= self.after_pixels.len() && dst_start + len <= layer.pixels.len() { if src_start + len <= self.after_pixels.len()
layer.pixels[dst_start..dst_start + len].copy_from_slice(&self.after_pixels[src_start..src_start + 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 { } else {
@@ -630,7 +724,9 @@ impl UndoableAction<Vec<Layer>> for DrawAction {
} }
layer.dirty = true; layer.dirty = true;
if let Some(ref shapes) = self.after_shapes { 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<Vec<Layer>> for LayerVisibilityAction {
self.description.clone() self.description.clone()
} }
} }
+5 -5
View File
@@ -14,25 +14,25 @@
dev: dev:
@echo "▶ HCIE Egui — Dev Mode" @echo "▶ HCIE Egui — Dev Mode"
cargo run -p hcie-gui-egui ../scripts/cargo-with-build-id.sh run -p hcie-gui-egui
# ── Production Builds ────────────────────────────────────────────────────── # ── Production Builds ──────────────────────────────────────────────────────
build: build:
@echo "▶ HCIE Egui — Release 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" @echo "→ Binary: target/release/hcie-gui"
# ── Validation ───────────────────────────────────────────────────────────── # ── Validation ─────────────────────────────────────────────────────────────
check: check:
cargo check --workspace ../scripts/cargo-with-build-id.sh check --workspace
test: test:
cargo test --workspace ../scripts/cargo-with-build-id.sh test --workspace
lint: lint:
cargo clippy --workspace -- --deny warnings ../scripts/cargo-with-build-id.sh clippy --workspace -- --deny warnings
help: help:
@echo "HCIE-Rust v3 — Egui Commands:" @echo "HCIE-Rust v3 — Egui Commands:"
@@ -1,7 +1,7 @@
[package] [package]
name = "egui-panel-adapter" name = "egui-panel-adapter"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[dependencies] [dependencies]
hcie-engine-api = { path = "../../../hcie-engine-api" } hcie-engine-api = { path = "../../../hcie-engine-api" }
@@ -1,7 +1,7 @@
[package] [package]
name = "egui-panel-ai-chat" name = "egui-panel-ai-chat"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[dependencies] [dependencies]
hcie-engine-api = { path = "../../../hcie-engine-api" } hcie-engine-api = { path = "../../../hcie-engine-api" }
@@ -1,7 +1,7 @@
[package] [package]
name = "egui-panel-ai-script" name = "egui-panel-ai-script"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[dependencies] [dependencies]
hcie-engine-api = { path = "../../../hcie-engine-api" } hcie-engine-api = { path = "../../../hcie-engine-api" }
@@ -1,7 +1,7 @@
[package] [package]
name = "egui-panel-filters" name = "egui-panel-filters"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[dependencies] [dependencies]
hcie-engine-api = { path = "../../../hcie-engine-api" } hcie-engine-api = { path = "../../../hcie-engine-api" }
@@ -1,7 +1,7 @@
[package] [package]
name = "egui-panel-script" name = "egui-panel-script"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[dependencies] [dependencies]
hcie-engine-api = { path = "../../../hcie-engine-api" } hcie-engine-api = { path = "../../../hcie-engine-api" }
@@ -1,7 +1,7 @@
[package] [package]
name = "hcie-gui-egui" name = "hcie-gui-egui"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[[bin]] [[bin]]
name = "hcie-gui" name = "hcie-gui"
@@ -12,6 +12,7 @@ default = ["tablet-evdev"]
tablet-evdev = ["dep:evdev"] tablet-evdev = ["dep:evdev"]
[dependencies] [dependencies]
hcie-build-info = { workspace = true }
hcie-engine-api = { path = "../../../hcie-engine-api" } hcie-engine-api = { path = "../../../hcie-engine-api" }
egui-panel-adapter = { path = "../egui-panel-adapter" } egui-panel-adapter = { path = "../egui-panel-adapter" }
egui-panel-filters = { path = "../egui-panel-filters" } egui-panel-filters = { path = "../egui-panel-filters" }
@@ -315,11 +315,10 @@ impl HcieApp {
/// ///
/// ### Logic & Workflow /// ### Logic & Workflow
/// 1. Registers image loaders and custom typography fonts to the egui context. /// 1. Registers image loaders and custom typography fonts to the egui context.
/// 2. Reads and increments a build run counter stored in `build.id`. /// 2. Reconstructs settings, tool configurations, colors, and AI chat state from persistent local storage.
/// 3. 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. 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. Restores the persisted column widths (`hcie_panel_widths`) to maintain user adjustments across launches. /// 5. Instantiates the UI and state manager.
/// 6. Instantiates the UI and state manager.
/// ///
/// ### Arguments /// ### Arguments
/// * `cc` - A reference to eframe's creation context containing settings and storage access. /// * `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. /// An initialized instance of `HcieApp` ready to be run by the eframe event loop.
/// ///
/// ### Side Effects / Dependencies /// ### Side Effects / Dependencies
/// - Performs file reads/writes on `build.id`.
/// - Connects to the local persistent storage engine. /// - Connects to the local persistent storage engine.
pub fn new( pub fn new(
cc: &eframe::CreationContext<'_>, cc: &eframe::CreationContext<'_>,
@@ -342,17 +340,6 @@ impl HcieApp {
// and widget can read its tuned pixel values from the very first frame. // and widget can read its tuned pixel values from the very first frame.
shell::gui_layout::init_global(); 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::<u64>() {
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 // Initialize column widths with default values before attempting to load them from storage
let mut last_window_width = 0.0; let mut last_window_width = 0.0;
let mut left_col1_w = 36.0; // Tools column is locked at 36px let mut left_col1_w = 36.0; // Tools column is locked at 36px
@@ -95,7 +95,7 @@ fn get_icon_tint(ctx: &egui::Context) -> Color32 {
/// ///
/// **Logic & Workflow:** Reads the active document name from `app.active_doc_ref()` /// **Logic & Workflow:** Reads the active document name from `app.active_doc_ref()`
/// and falls back to `Untitled`. The full version is taken from /// 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:** /// **Arguments:**
/// * `app`: Reference to application state for the active document name. /// * `app`: Reference to application state for the active document name.
@@ -106,7 +106,7 @@ fn build_title(app: &HcieApp) -> String {
.active_doc_ref() .active_doc_ref()
.map(|d| d.name.clone()) .map(|d| d.name.clone())
.unwrap_or_else(|| "Untitled".to_string()); .unwrap_or_else(|| "Untitled".to_string());
let version = env!("CARGO_PKG_VERSION"); let version = hcie_build_info::VERSION;
format!("HCIE v{version}{doc}") format!("HCIE v{version}{doc}")
} }
+13 -27
View File
@@ -29,6 +29,8 @@ use hcie_tile::TiledLayer;
use std::path::Path; use std::path::Path;
use std::sync::Mutex; 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_blend::Adjustment;
pub use hcie_brush_engine::presets::BrushPreset; pub use hcie_brush_engine::presets::BrushPreset;
pub use hcie_protocol::thumbnail_nearest; pub use hcie_protocol::thumbnail_nearest;
@@ -41,8 +43,6 @@ pub use hcie_protocol::{
FilterType, LayerData, LayerInfo, LayerStyle, LayerType, LineCap, ModulePanel, ToolConfigs, FilterType, LayerData, LayerInfo, LayerStyle, LayerType, LineCap, ModulePanel, ToolConfigs,
VectorEditHandle, VectorShape, WidgetDescription, ZOOM_MAX, ZOOM_MIN, 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 mod brush {
pub use hcie_brush_engine::{BrushStyle, BrushTip}; pub use hcie_brush_engine::{BrushStyle, BrushTip};
} }
@@ -556,8 +556,7 @@ impl Engine {
cached_selection_mask: None, cached_selection_mask: None,
pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
shape_catalog: { shape_catalog: {
let base = dirs::data_local_dir() let base = dirs::data_local_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
.unwrap_or_else(|| std::path::PathBuf::from("."));
let shapes_dir = base.join("hcie").join("shapes"); let shapes_dir = base.join("hcie").join("shapes");
crate::shape_catalog::ShapeCatalog::new(shapes_dir) crate::shape_catalog::ShapeCatalog::new(shapes_dir)
}, },
@@ -850,9 +849,7 @@ impl Engine {
let idx = y * w + x; let idx = y * w + x;
if mask[idx] == 0 { if mask[idx] == 0 {
let pidx = idx * 4; let pidx = idx * 4;
if pidx + 3 < layer.pixels.len() if pidx + 3 < layer.pixels.len() && pidx + 3 < before_pixels.len() {
&& pidx + 3 < before_pixels.len()
{
layer.pixels[pidx] = before_pixels[pidx]; layer.pixels[pidx] = before_pixels[pidx];
layer.pixels[pidx + 1] = before_pixels[pidx + 1]; layer.pixels[pidx + 1] = before_pixels[pidx + 1];
layer.pixels[pidx + 2] = before_pixels[pidx + 2]; layer.pixels[pidx + 2] = before_pixels[pidx + 2];
@@ -899,9 +896,7 @@ impl Engine {
let idx = y * w + x; let idx = y * w + x;
if mask[idx] == 0 { if mask[idx] == 0 {
let pidx = idx * 4; let pidx = idx * 4;
if pidx + 3 < layer.pixels.len() if pidx + 3 < layer.pixels.len() && pidx + 3 < saved_before.len() {
&& pidx + 3 < saved_before.len()
{
layer.pixels[pidx] = saved_before[pidx]; layer.pixels[pidx] = saved_before[pidx];
layer.pixels[pidx + 1] = saved_before[pidx + 1]; layer.pixels[pidx + 1] = saved_before[pidx + 1];
layer.pixels[pidx + 2] = saved_before[pidx + 2]; layer.pixels[pidx + 2] = saved_before[pidx + 2];
@@ -1346,23 +1341,14 @@ impl Engine {
return; return;
} }
} }
// Active layer is NOT vector — create a NEW vector layer (matching V2 behavior) // Create the final vector layer before recording history. This keeps the first shape and
let new_id = self // its auto-created layer in one atomic Undo/Redo entry instead of capturing an empty
.document // intermediate raster layer.
.add_layer(&format!("Vector {}", self.document.layers.len())); self.document.add_vector_layer_with_shape(
if let Some(idx) = self.document.layer_index_by_id(new_id) { format!("Vector {}", self.document.layers.len()),
// Set as vector layer shape,
if let Some(layer) = self.document.get_layer_by_id_mut(new_id) { "Add Vector Shape",
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);
}
} }
pub fn get_svg_source(&self, layer_id: u64) -> Option<&[u8]> { pub fn get_svg_source(&self, layer_id: u64) -> Option<&[u8]> {
@@ -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)
);
}
+42 -3
View File
@@ -47,8 +47,9 @@ impl<S> HistoryManager<S> {
} }
pub fn redo(&mut self, state: &mut S) { pub fn redo(&mut self, state: &mut S) {
if self.current_index >= 0 && (self.current_index as usize) + 1 < self.entries.len() { let next_index = self.current_index + 1;
let idx = (self.current_index + 1) as usize; if next_index >= 0 && (next_index as usize) < self.entries.len() {
let idx = next_index as usize;
self.entries[idx].redo(state); self.entries[idx].redo(state);
self.current_index += 1; self.current_index += 1;
} }
@@ -59,7 +60,8 @@ impl<S> HistoryManager<S> {
} }
pub fn can_redo(&self) -> bool { 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 { pub fn len(&self) -> usize {
@@ -98,3 +100,40 @@ impl<S> HistoryManager<S> {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::{HistoryManager, UndoableAction};
/// Minimal additive action used to verify history cursor boundaries.
struct Add(i32);
impl UndoableAction<i32> 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);
}
}
+37 -12
View File
@@ -41,10 +41,14 @@ fn test_history_push_undo_redo() {
let mut layers = vec![make_layer()]; let mut layers = vec![make_layer()];
let mut hist = HistoryManager::<Vec<Layer>>::new(10); let mut hist = HistoryManager::<Vec<Layer>>::new(10);
hist.push(Box::new(SetPixelAction { 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 { 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_undo());
assert!(!hist.can_redo()); assert!(!hist.can_redo());
@@ -62,7 +66,9 @@ fn test_history_undo_twice_is_stable() {
let mut layers = vec![make_layer()]; let mut layers = vec![make_layer()];
let mut hist = HistoryManager::<Vec<Layer>>::new(10); let mut hist = HistoryManager::<Vec<Layer>>::new(10);
hist.push(Box::new(SetPixelAction { 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);
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 layers = vec![make_layer()];
let mut hist = HistoryManager::<Vec<Layer>>::new(10); let mut hist = HistoryManager::<Vec<Layer>>::new(10);
hist.push(Box::new(SetPixelAction { 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);
hist.redo(&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 layers = vec![make_layer()];
let mut hist = HistoryManager::<Vec<Layer>>::new(10); let mut hist = HistoryManager::<Vec<Layer>>::new(10);
hist.push(Box::new(SetPixelAction { 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 { 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); hist.undo(&mut layers);
assert!(hist.can_redo()); assert!(hist.can_redo());
hist.push(Box::new(SetPixelAction { 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"); 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 layers = vec![make_layer()];
let mut hist = HistoryManager::<Vec<Layer>>::new(10); let mut hist = HistoryManager::<Vec<Layer>>::new(10);
hist.push(Box::new(SetPixelAction { 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 { 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 { 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); hist.jump_to(-1, &mut layers);
assert!(!hist.can_undo(), "jump to -1 should be at bottom"); 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]); assert_eq!(layers[0].get_pixel(0, 0), [0; 4]);
} }
@@ -138,7 +161,9 @@ fn test_history_jump_to() {
fn test_entry_description() { fn test_entry_description() {
let mut hist = HistoryManager::<Vec<Layer>>::new(10); let mut hist = HistoryManager::<Vec<Layer>>::new(10);
hist.push(Box::new(SetPixelAction { 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(0), Some("SetPixel".to_string()));
assert_eq!(hist.entry_description(99), None); assert_eq!(hist.entry_description(99), None);
+21
View File
@@ -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"
@@ -1,7 +1,7 @@
[package] [package]
name = "hcie-iced-gui" name = "hcie-iced-gui"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[lib] [lib]
name = "hcie_iced_gui" name = "hcie_iced_gui"
@@ -16,6 +16,7 @@ name = "hcie-iced"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
hcie-build-info = { workspace = true }
hcie-engine-api = { path = "../../../hcie-engine-api" } hcie-engine-api = { path = "../../../hcie-engine-api" }
iced-panel-adapter = { path = "../iced-panel-adapter" } iced-panel-adapter = { path = "../iced-panel-adapter" }
iced = { workspace = true } iced = { workspace = true }
+236 -65
View File
@@ -100,7 +100,11 @@ fn load_colors() -> PersistedColors {
.unwrap_or_default(), .unwrap_or_default(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PersistedColors::default(), Err(e) if e.kind() == std::io::ErrorKind::NotFound => PersistedColors::default(),
Err(e) => { 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() PersistedColors::default()
} }
} }
@@ -171,8 +175,8 @@ pub struct HcieIcedApp {
pub active_dialog: ActiveDialog, pub active_dialog: ActiveDialog,
/// Index of the active tool slot in the sidebar (for sub-tools). /// Index of the active tool slot in the sidebar (for sub-tools).
pub active_tool_slot: usize, pub active_tool_slot: usize,
/// Whether the sub-tools popup is open for the active slot. /// Slot whose expanded sub-tool chooser remains open until toggled again.
pub sub_tools_open: bool, pub open_subtool_slot: Option<usize>,
/// Per-slot last-used sub-tool index (egui's `slot_last_used`). The sidebar /// 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 /// shows the last-used tool's icon/label for each slot rather than always
/// the primary tool. /// the primary tool.
@@ -323,11 +327,18 @@ pub struct HcieIcedApp {
pub show_dock_profile_dialog: bool, pub show_dock_profile_dialog: bool,
/// Current UI language for internationalization. /// Current UI language for internationalization.
pub _language: i18n::Language, 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. /// Context menu screen position (x, y). None means closed.
pub canvas_context_menu: Option<(f32, f32)>, pub canvas_context_menu: Option<(f32, f32)>,
/// Dirty flag set whenever fg/bg/recent colors change, so colors can be /// Dirty flag set whenever fg/bg/recent colors change, so colors can be
/// persisted to disk (egui persists these under the `hcie_colors` key). /// persisted to disk (egui persists these under the `hcie_colors` key).
pub colors_dirty: bool, 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). /// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration).
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>, pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
/// Cached `(kind, svg)` pairs from the engine's shape catalog for the active document. /// 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, pub last_update_instant: std::time::Instant,
/// Imported brush presets from ABR/KPP files. /// Imported brush presets from ABR/KPP files.
pub imported_brushes: Vec<hcie_engine_api::BrushPreset>, pub imported_brushes: Vec<hcie_engine_api::BrushPreset>,
/// Complete preset currently supplying non-style brush-tip parameters.
pub active_brush_preset: Option<hcie_engine_api::BrushPreset>,
/// True if currently holding shift to resize brush /// True if currently holding shift to resize brush
pub is_resizing_brush: bool, pub is_resizing_brush: bool,
/// Starting brush size before resize drag /// Starting brush size before resize drag
@@ -596,6 +609,7 @@ impl Default for ToolState {
last_composite_refresh: std::time::Instant::now(), last_composite_refresh: std::time::Instant::now(),
last_update_instant: std::time::Instant::now(), last_update_instant: std::time::Instant::now(),
imported_brushes: Vec::new(), imported_brushes: Vec::new(),
active_brush_preset: None,
is_resizing_brush: false, is_resizing_brush: false,
brush_resize_start_size: 20.0, brush_resize_start_size: 20.0,
brush_resize_start_pos: None, brush_resize_start_pos: None,
@@ -621,6 +635,10 @@ pub enum Message {
// ── Color ─────────────────────────────────────────── // ── Color ───────────────────────────────────────────
FgColorChanged([u8; 4]), FgColorChanged([u8; 4]),
BgColorChanged([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, SwapColors,
ResetColors, ResetColors,
ColorTabChanged(usize), ColorTabChanged(usize),
@@ -719,6 +737,8 @@ pub enum Message {
// ── Brushes ───────────────────────────────────────── // ── Brushes ─────────────────────────────────────────
BrushStyleSelected(BrushStyle), BrushStyleSelected(BrushStyle),
BrushPresetSelected(hcie_engine_api::BrushPreset),
BrushCategorySelected(crate::panels::brushes::BrushCategory),
BrushSizeChanged(f32), BrushSizeChanged(f32),
BrushSizeRelative(f32), BrushSizeRelative(f32),
BrushOpacityChanged(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. /// Convert a display label back to a PaneType for dock profile loading.
fn pane_type_from_label(label: &str) -> Option<crate::dock::state::PaneType> { fn pane_type_from_label(label: &str) -> Option<crate::dock::state::PaneType> {
use crate::dock::state::PaneType; use crate::dock::state::PaneType;
@@ -1605,9 +1698,9 @@ impl HcieIcedApp {
active_dialog: ActiveDialog::None, active_dialog: ActiveDialog::None,
active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush) active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush)
.unwrap_or(0), .unwrap_or(0),
sub_tools_open: false, open_subtool_slot: None,
sidebar_expanded: settings.panel_layout.sidebar_expanded, sidebar_expanded: settings.panel_layout.sidebar_expanded,
slot_last_used: vec![0; 20], slot_last_used: vec![0; 21],
_dialog_input: String::new(), _dialog_input: String::new(),
_dialog_input2: String::new(), _dialog_input2: String::new(),
_show_performance: false, _show_performance: false,
@@ -1692,7 +1785,10 @@ impl HcieIcedApp {
dock_profile_rename_buf: String::new(), dock_profile_rename_buf: String::new(),
show_dock_profile_dialog: false, show_dock_profile_dialog: false,
_language: i18n::Language::default(), _language: i18n::Language::default(),
brush_category: crate::panels::brushes::BrushCategory::All,
colors_dirty: false, colors_dirty: false,
color_dragging: false,
pending_drag_color: None,
vector_shapes_snapshot: None, vector_shapes_snapshot: None,
cached_custom_shapes: Vec::new(), cached_custom_shapes: Vec::new(),
vector_drag_last: None, vector_drag_last: None,
@@ -2109,10 +2205,19 @@ impl HcieIcedApp {
} }
/// Add a color to the recent colors list (most recent first, max 40). /// 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]) { fn add_recent_color(&mut self, color: [u8; 4]) {
self.recent_colors.retain(|c| *c != color); self.recent_colors.retain(|c| *c != color);
self.recent_colors.insert(0, color); self.recent_colors.insert(0, color);
self.recent_colors.truncate(40); 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. /// 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 /// Called before starting a stroke to ensure the engine uses
/// the current brush size, opacity, hardness, and style. /// the current brush size, opacity, hardness, and style.
fn apply_brush_params(&mut self) { 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 { match self.tool_state.active_tool {
Tool::Pen => { Tool::Pen => {
tip.style = BrushStyle::Round; tip.style = BrushStyle::Round;
@@ -2354,7 +2469,6 @@ impl HcieIcedApp {
Message::ToolSelected(tool) => { Message::ToolSelected(tool) => {
log::info!("Tool selected: {:?}", tool); log::info!("Tool selected: {:?}", tool);
self.tool_state.active_tool = tool; self.tool_state.active_tool = tool;
self.sub_tools_open = false;
if tool == Tool::Brush { if tool == Tool::Brush {
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes); self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
} }
@@ -2386,6 +2500,7 @@ impl HcieIcedApp {
} }
Message::ToolSlotClicked(slot_idx, primary_tool) => { Message::ToolSlotClicked(slot_idx, primary_tool) => {
let same_slot = self.active_tool_slot == slot_idx;
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 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() { 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; self.tool_state.active_tool = selected_tool;
if selected_tool == Tool::Brush { if selected_tool == Tool::Brush {
self.dock self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
.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) => { Message::SubtoolsToggle(slot_idx) => {
let supports_popup = crate::sidebar::tool_slots() let supports_popup = crate::sidebar::tool_slots()
.get(slot_idx) .get(slot_idx)
.is_some_and(|slot| slot.tools.len() > 1); .is_some_and(|slot| slot.tools.len() > 1);
if supports_popup { if supports_popup {
let opening = !(self.sub_tools_open && self.active_tool_slot == slot_idx); let opening = self.open_subtool_slot != Some(slot_idx);
self.sub_tools_open = opening; self.open_subtool_slot = opening.then_some(slot_idx);
self.active_tool_slot = slot_idx; self.active_tool_slot = slot_idx;
if opening && !self.sidebar_expanded { if opening && !self.sidebar_expanded {
self.sidebar_expanded = true; self.sidebar_expanded = true;
@@ -2414,13 +2541,14 @@ impl HcieIcedApp {
self.settings.panel_layout.sidebar_width = 72.0; self.settings.panel_layout.sidebar_width = 72.0;
let _ = self.settings.save(); 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 { if opening {
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used); let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
if tool == Tool::Brush { if tool == Tool::Brush {
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
}
if matches!(tool, Tool::CustomShape(_)) {
self.dock 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() { if self.dock.active_auto_hide.take().is_some() {
return Task::none(); return Task::none();
} }
if self.sub_tools_open { self.active_menu = None;
self.sub_tools_open = false;
} else {
self.active_menu = None;
}
} }
Message::FgColorChanged(color) => { Message::FgColorChanged(color) => {
self.fg_color = color; self.fg_color = color;
self.active_document_mut().engine.set_color(color); self.active_document_mut().engine.set_color(color);
self.add_recent_color(color); if self.color_dragging {
self.colors_dirty = true; self.pending_drag_color = Some(color);
crate::shape_sync::sync_shape_from_tool(self); } else {
self.refresh_composite_if_needed(); 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) => { Message::BgColorChanged(color) => {
self.bg_color = color; self.bg_color = color;
self.add_recent_color(color);
self.colors_dirty = true; 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); crate::shape_sync::sync_shape_from_tool(self);
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
} }
@@ -2674,6 +2822,8 @@ impl HcieIcedApp {
]; ];
self.fg_color = color; self.fg_color = color;
self.documents[self.active_doc].engine.set_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.fg_color = color;
self.documents[self.active_doc].engine.set_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 { } else if self.tool_state.is_resizing_brush {
@@ -4479,6 +4631,21 @@ impl HcieIcedApp {
Message::BrushStyleSelected(style) => { Message::BrushStyleSelected(style) => {
log::info!("Brush style selected: {:?}", style); log::info!("Brush style selected: {:?}", style);
self.tool_state.brush_style = 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) => { Message::BrushSizeChanged(size) => {
self.tool_state.brush_size = size; self.tool_state.brush_size = size;
@@ -5576,22 +5743,22 @@ impl HcieIcedApp {
// Use cached catalog snapshot if available; otherwise read once from the // Use cached catalog snapshot if available; otherwise read once from the
// active document's shape catalog. `cached_custom_shapes` is populated/updated // active document's shape catalog. `cached_custom_shapes` is populated/updated
// by `RefreshCustomShapes` so drawing remains consistent with the panel. // by `RefreshCustomShapes` so drawing remains consistent with the panel.
let custom_shapes: Vec<(String, String)> = if self.cached_custom_shapes.is_empty() let custom_shapes: Vec<(String, String)> =
{ if self.cached_custom_shapes.is_empty() {
self.documents self.documents
.get(self.active_doc) .get(self.active_doc)
.map(|d| { .map(|d| {
d.engine d.engine
.shape_catalog .shape_catalog
.all() .all()
.iter() .iter()
.map(|e| (e.kind.clone(), e.svg.clone())) .map(|e| (e.kind.clone(), e.svg.clone()))
.collect() .collect()
}) })
.unwrap_or_default() .unwrap_or_default()
} else { } else {
self.cached_custom_shapes.clone() self.cached_custom_shapes.clone()
}; };
let engine = &mut self.documents[self.active_doc].engine; let engine = &mut self.documents[self.active_doc].engine;
let color = self.fg_color; let color = self.fg_color;
@@ -6298,9 +6465,11 @@ impl HcieIcedApp {
.active_vector_shapes() .active_vector_shapes()
.map_or(0, |s| s.len()); .map_or(0, |s| s.len());
if idx + 1 < count { if idx + 1 < count {
self.documents[self.active_doc] self.documents[self.active_doc].engine.reorder_vector_shape(
.engine layer_id,
.reorder_vector_shape(layer_id, idx, idx + 1); idx,
idx + 1,
);
self.documents[self.active_doc].selected_vector_shape = Some(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].modified = true;
self.documents[self.active_doc] self.documents[self.active_doc]
@@ -6316,9 +6485,11 @@ impl HcieIcedApp {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
if idx > 0 { if idx > 0 {
let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc] self.documents[self.active_doc].engine.reorder_vector_shape(
.engine layer_id,
.reorder_vector_shape(layer_id, idx, idx - 1); idx,
idx - 1,
);
self.documents[self.active_doc].selected_vector_shape = Some(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].modified = true;
self.documents[self.active_doc] self.documents[self.active_doc]
@@ -6601,11 +6772,13 @@ impl HcieIcedApp {
.engine .engine
.set_vector_shape_fill_color(layer_id, idx, c); .set_vector_shape_fill_color(layer_id, idx, c);
self.documents[self.active_doc].modified = true; self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self); if !self.color_dragging {
self.documents[self.active_doc] crate::shape_sync::sync_tool_from_shape(self);
.engine self.documents[self.active_doc]
.mark_composite_dirty(); .engine
self.refresh_composite_if_needed(); .mark_composite_dirty();
self.refresh_composite_if_needed();
}
} }
} }
Message::GeometryStrokeColorChanged(c) => { Message::GeometryStrokeColorChanged(c) => {
@@ -6615,11 +6788,13 @@ impl HcieIcedApp {
.engine .engine
.set_vector_shape_color(layer_id, idx, c); .set_vector_shape_color(layer_id, idx, c);
self.documents[self.active_doc].modified = true; self.documents[self.active_doc].modified = true;
crate::shape_sync::sync_tool_from_shape(self); if !self.color_dragging {
self.documents[self.active_doc] crate::shape_sync::sync_tool_from_shape(self);
.engine self.documents[self.active_doc]
.mark_composite_dirty(); .engine
self.refresh_composite_if_needed(); .mark_composite_dirty();
self.refresh_composite_if_needed();
}
} }
} }
Message::GeometryOpacityChanged(v) => { Message::GeometryOpacityChanged(v) => {
@@ -7962,7 +8137,6 @@ impl HcieIcedApp {
// ── Menu ───────────────────────────────────── // ── Menu ─────────────────────────────────────
Message::MenuOpen(idx) => { Message::MenuOpen(idx) => {
self.sub_tools_open = false;
if self.active_menu == Some(idx) { if self.active_menu == Some(idx) {
self.active_menu = None; self.active_menu = None;
} else { } else {
@@ -8929,11 +9103,8 @@ impl HcieIcedApp {
} }
// Mirrors egui's Escape handling. Priority: viewer > crop > // Mirrors egui's Escape handling. Priority: viewer > crop >
// transform > vector selection > selection > dialog > viewer fallback. // transform > vector selection > selection > dialog > viewer fallback.
match overlay_escape_target(self.sub_tools_open, self.active_menu.is_some()) { match overlay_escape_target(false, self.active_menu.is_some()) {
Some(OverlayEscapeTarget::Subtools) => { Some(OverlayEscapeTarget::Subtools) => unreachable!(),
self.sub_tools_open = false;
return Task::none();
}
Some(OverlayEscapeTarget::Menu) => { Some(OverlayEscapeTarget::Menu) => {
self.active_menu = None; self.active_menu = None;
return Task::none(); return Task::none();
@@ -8944,7 +9115,7 @@ impl HcieIcedApp {
return self.update(Message::ViewerToggle); return self.update(Message::ViewerToggle);
} else if matches!(self.active_dialog, ActiveDialog::SvgEditor) { } else if matches!(self.active_dialog, ActiveDialog::SvgEditor) {
return self.update(Message::SvgEditorCancel); 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(); return Task::none();
} else if self.documents[self.active_doc].crop_state.active { } else if self.documents[self.active_doc].crop_state.active {
return self.update(Message::CropCancel); return self.update(Message::CropCancel);
@@ -9059,8 +9230,7 @@ impl HcieIcedApp {
&self.fg_color, &self.fg_color,
&self.bg_color, &self.bg_color,
colors, colors,
self.active_tool_slot, self.open_subtool_slot,
self.sub_tools_open,
&self.slot_last_used, &self.slot_last_used,
self.sidebar_expanded, self.sidebar_expanded,
); );
@@ -9226,6 +9396,7 @@ impl HcieIcedApp {
self.theme_state.colors(), self.theme_state.colors(),
Some(&self.dock), Some(&self.dock),
self.settings.window.width, self.settings.window.width,
self.settings.window.height,
) { ) {
stack = stack.push(menu_overlay); stack = stack.push(menu_overlay);
} }
@@ -10,11 +10,10 @@
use crate::app::Message; use crate::app::Message;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use iced::mouse; use iced::mouse;
use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke}; use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke};
use iced::widget::{ use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
button, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
};
use iced::{Element, Length, Padding, Point, Rectangle, Size}; use iced::{Element, Length, Padding, Point, Rectangle, Size};
/// Identifies the application value edited by the shared color selector. /// 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 { let body: Element<'static, Message> = match tab {
0 => rgb_popup_section(color), 0 => rgb_popup_section(color, colors),
1 => Canvas::new(ColorWheel { 1 => Canvas::new(ColorWheel {
color, color,
bg_color: [0, 0, 0, 255],
output: WheelOutput::Popup, output: WheelOutput::Popup,
}) })
.width(Length::Fixed(220.0)) .width(Length::Fixed(220.0))
.height(Length::Fixed(220.0)) .height(Length::Fixed(220.0))
.into(), .into(),
_ => hsl_popup_section(color, h, s, l), _ => hsl_popup_section(color, h, s, l, colors),
}; };
let alpha = color[3]; let alpha = color[3];
let alpha_row = row![ let alpha_row = plain_slider(
text("A").size(10).width(16), "Alpha",
slider(0..=255u8, alpha, move |value| { alpha as f32,
Message::PopupColorChanged([color[0], color[1], color[2], value]) 0.0..=255.0,
}) 1.0,
.width(Length::Fill), "",
text(format!("{alpha}")).size(10).width(28), 0,
] colors,
.spacing(5) move |value| {
.align_y(iced::Alignment::Center); Message::PopupColorChanged([color[0], color[1], color[2], value.round() as u8])
},
);
let popup = column![ let popup = column![
row![ row![
@@ -356,32 +358,23 @@ pub fn popup_view(
} }
/// Builds RGB sliders and directly editable byte values. /// 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 channel = |label, index: usize| {
let slider_color = color; let slider_color = color;
let input_color = color; plain_slider(
row![ label,
text(label).size(10).width(16), color[index] as f32,
slider(0..=255u8, color[index], move |value| { 0.0..=255.0,
1.0,
"",
0,
colors,
move |value| {
let mut next = slider_color; let mut next = slider_color;
next[index] = value; next[index] = value.round() as u8;
Message::PopupColorChanged(next) Message::PopupColorChanged(next)
}) },
.width(Length::Fill), )
text_input("0", &color[index].to_string())
.on_input(move |value| match value.parse::<u8>() {
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)] column![channel("R", 0), channel("G", 1), channel("B", 2)]
.spacing(5) .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. /// 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 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); let (r, g, b) = hsl_to_rgb(value, s, l);
Message::PopupColorChanged([r, g, b, alpha]) Message::PopupColorChanged([r, g, b, alpha])
}); });
let saturation = slider(0.0..=100.0, s * 100.0, move |value| { 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 / 100.0, l); let (r, g, b) = hsl_to_rgb(h, value, l);
Message::PopupColorChanged([r, g, b, alpha]) Message::PopupColorChanged([r, g, b, alpha])
}); });
let lightness = slider(0.0..=100.0, l * 100.0, move |value| { 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 / 100.0); let (r, g, b) = hsl_to_rgb(h, s, value);
Message::PopupColorChanged([r, g, b, alpha]) Message::PopupColorChanged([r, g, b, alpha])
}); });
column![ column![hue, saturation, lightness].spacing(5).into()
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()
} }
/// Interactive hue ring and saturation/lightness square. /// Interactive hue ring and saturation/lightness square.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct ColorWheel { struct ColorWheel {
color: [u8; 4], color: [u8; 4],
bg_color: [u8; 4],
output: WheelOutput, output: WheelOutput,
} }
@@ -564,6 +542,7 @@ impl canvas::Program<Message> for ColorWheel {
square_origin.x + s * square_size, square_origin.x + s * square_size,
square_origin.y + (1.0 - l) * 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] { for point in [hue_point, sl_point] {
frame.fill(&Path::circle(point, 5.0), iced::Color::WHITE); frame.fill(&Path::circle(point, 5.0), iced::Color::WHITE);
frame.stroke( frame.stroke(
@@ -573,6 +552,29 @@ impl canvas::Program<Message> for ColorWheel {
.with_width(1.5), .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()] vec![frame.into_geometry()]
} }
@@ -588,7 +590,10 @@ impl canvas::Program<Message> for ColorWheel {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
state.dragging = true; 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)) => { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
@@ -605,7 +610,13 @@ impl canvas::Program<Message> for ColorWheel {
} }
} }
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { 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], bg_color: &'a [u8; 4],
recent_colors: &'a [[u8; 4]], recent_colors: &'a [[u8; 4]],
color_tab: usize, color_tab: usize,
colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let r = fg_color[0]; let r = fg_color[0];
let g = fg_color[1]; let g = fg_color[1];
@@ -714,19 +726,24 @@ pub fn view<'a>(
// Allows transparency editing, which egui supports in the layer-style panel // Allows transparency editing, which egui supports in the layer-style panel
// but the iced picker previously stripped entirely. Slider 0..255. // but the iced picker previously stripped entirely. Slider 0..255.
let a_val = fg_color[3]; let a_val = fg_color[3];
let alpha_slider = slider(0..=255u8, a_val, move |v| { let alpha_rgb = [fg_color[0], fg_color[1], fg_color[2]];
Message::FgColorChanged([fg_color[0], fg_color[1], fg_color[2], v]) let alpha_row = plain_slider(
}) "Alpha",
.width(Length::Fill); a_val as f32 / 255.0,
let alpha_row = row![ 0.0..=1.0,
text("A").size(10).width(Length::Fixed(12.0)), 0.01,
alpha_slider, "%",
text(format!("{:.0}%", a_val as f32 / 255.0 * 100.0)) 0,
.size(9) colors,
.width(Length::Fixed(34.0)), move |value| {
] Message::FgColorChanged([
.spacing(4) alpha_rgb[0],
.align_y(iced::Alignment::Center); alpha_rgb[1],
alpha_rgb[2],
(value * 255.0).round() as u8,
])
},
);
// ── 3. Tab bar ── // ── 3. Tab bar ──
let tab_w = 30; let tab_w = 30;
@@ -758,14 +775,14 @@ pub fn view<'a>(
// ── 4. Tab content ── // ── 4. Tab content ──
let tab_content: Element<'_, Message> = match color_tab { let tab_content: Element<'_, Message> = match color_tab {
0 => build_wheel_view(h, s, l, fg_color), 0 => build_wheel_view(h, s, l, fg_color, bg_color),
1 => build_slider_view(h, s, l, fg_color[3]), 1 => build_slider_view(h, s, l, fg_color[3], colors),
_ => build_grid_view(fg_color), _ => build_grid_view(fg_color),
}; };
// ── 5. Recent colors ── // ── 5. Recent colors ──
let mut recent_items: Vec<Element<'_, Message>> = Vec::new(); let mut recent_items: Vec<Element<'_, Message>> = Vec::new();
for &color in recent_colors.iter().take(20) { for &color in recent_colors.iter().take(16) {
let c = color; let c = color;
let is_selected = c == *fg_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; 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 iced::Color::WHITE
}; };
let dot = text(if is_selected { "\u{2022}" } else { "" }) let bg = iced::Color::from_rgba(
.size(10) c[0] as f32 / 255.0,
.style(move |_theme| iced::widget::text::Style { c[1] as f32 / 255.0,
color: Some(dot_color), c[2] as f32 / 255.0,
}); c[3] as f32 / 255.0,
);
// Wrap the tiny swatch in a transparent padded container so the let swatch = container(iced::widget::Space::with_width(18).height(18))
// `mouse_area` always has a non-empty hit target, including for right-clicks. .width(18)
let swatch = container(dot) .height(18)
.width(13)
.height(13)
.center_x(Length::Fill)
.center_y(Length::Fill)
.padding(2)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( background: Some(iced::Background::Color(bg)),
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
c[3] as f32 / 255.0,
))),
border: if is_selected { border: if is_selected {
iced::Border::default() iced::Border::default()
.color(dot_color) .color(dot_color)
.width(1.0) .width(1.5)
.rounded(2) .rounded(2)
} else { } 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() ..Default::default()
}); });
let clickable = iced::widget::mouse_area(swatch)
let clickable: Element<'_, Message> = iced::widget::mouse_area(swatch)
.on_press(Message::FgColorChanged(c)) .on_press(Message::FgColorChanged(c))
.on_right_press(Message::BgColorChanged(c)) .on_right_press(Message::BgColorChanged(c))
.interaction(iced::mouse::Interaction::Pointer); .interaction(iced::mouse::Interaction::Pointer)
recent_items.push(clickable.into()); .into();
recent_items.push(clickable);
} }
let recent_count = recent_items.len();
let recent_row: Element<'_, Message> = if recent_items.is_empty() { let recent_row: Element<'_, Message> = if recent_items.is_empty() {
text("").into() text("No recent colors").size(9).into()
} else { } 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<Element<'_, Message>> = 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<Element<'_, Message>> = 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![ let content = column![
text("Color").size(11).font(iced::Font::MONOSPACE), text("Color").size(11).font(iced::Font::MONOSPACE),
swatch_row, swatch_row,
hex_row, hex_row,
alpha_row, alpha_row,
recent_section,
horizontal_rule(1),
tabs, tabs,
tab_content, tab_content,
horizontal_rule(1),
text("Recent").size(9),
recent_row,
] ]
.width(Length::Fill) .width(Length::Fill)
.height(Length::Shrink) .height(Length::Shrink)
.spacing(3) .spacing(3)
.padding(Padding::from([4, 4])); .padding(Padding {
top: 4.0,
right: 14.0,
bottom: 4.0,
left: 4.0,
});
scrollable(content) scrollable(content)
.width(Length::Fill) .width(Length::Fill)
@@ -844,7 +879,13 @@ pub fn view<'a>(
/// ///
/// The wheel fills narrow pane widths while retaining intrinsic vertical height, avoiding the /// 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. /// 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!( let readout = text(format!(
"H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", "H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%",
h, 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 { let wheel = Canvas::new(ColorWheel {
color: *fg_color, color: *fg_color,
bg_color: *bg_color,
output: WheelOutput::Foreground, output: WheelOutput::Foreground,
}) })
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fixed(220.0)); .height(Length::Fixed(160.0));
column![wheel, readout].spacing(3).into() column![wheel, readout].spacing(3).into()
} }
/// Build the HSL sliders view (tab H). /// Build the HSL sliders view (tab H).
fn build_slider_view<'a>(h: f32, s: f32, l: f32, alpha: u8) -> Element<'a, Message> { fn build_slider_view<'a>(
let h_slider = slider(0.0..=360.0, h, move |v: f32| { 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); let (r, g, b) = hsl_to_rgb(v, s, l);
Message::FgColorChanged([r, g, b, alpha]) Message::FgColorChanged([r, g, b, alpha])
}) });
.step(1.0); let s_slider = plain_slider("S", s, 0.0..=1.0, 0.01, "%", 0, colors, move |v| {
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| { let (r, g, b) = hsl_to_rgb(h, v, l);
let (r, g, b) = hsl_to_rgb(h, v / 100.0, l);
Message::FgColorChanged([r, g, b, alpha]) Message::FgColorChanged([r, g, b, alpha])
}) });
.step(1.0); let l_slider = plain_slider("L", l, 0.0..=1.0, 0.01, "%", 0, colors, move |v| {
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| { let (r, g, b) = hsl_to_rgb(h, s, v);
let (r, g, b) = hsl_to_rgb(h, s, v / 100.0);
Message::FgColorChanged([r, g, b, alpha]) Message::FgColorChanged([r, g, b, alpha])
}) });
.step(1.0);
column![ column![h_slider, s_slider, l_slider].spacing(2).into()
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()
} }
/// 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> { fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
let mut palette_grid = column![].spacing(1); const GRID_ROWS: usize = 10;
for row_idx in 0..10 { const GRID_COLS: usize = 10;
let mut row_widgets = row![].spacing(1); let gap: f32 = 2.0;
for col_idx in 0..10 { 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<Element<'a, Message>> = Vec::new();
for row_idx in 0..GRID_ROWS {
let mut row_widgets: Vec<Element<'a, Message>> = Vec::new();
for col_idx in 0..GRID_COLS {
let c = palette_color(row_idx, col_idx); 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 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 iced::Color::BLACK
} else { } else {
iced::Color::WHITE iced::Color::WHITE
}; };
let dot = text(if is_selected { "\u{2022}" } else { "" }) let bg = iced::Color::from_rgb(
.size(10) c[0] as f32 / 255.0,
.style(move |_theme| iced::widget::text::Style { c[1] as f32 / 255.0,
color: Some(dot_color), c[2] as f32 / 255.0,
}); );
// Pad the palette cell so the `mouse_area` has a non-empty hit // Inner colored swatch with optional selection indicator (border + center dot).
// target even when the inner swatch is only 14×14 pixels. let swatch_bg = bg;
let swatch = container(dot) let border_color = sc;
.width(14) let swatch = container(text(""))
.height(14) .width(cell)
.center_x(Length::Fill) .height(cell)
.center_y(Length::Fill)
.padding(2)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb( background: Some(iced::Background::Color(swatch_bg)),
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
))),
border: if is_selected { border: if is_selected {
iced::Border::default() iced::Border::default()
.color(dot_color) .color(border_color)
.width(1.0) .width(1.5)
.rounded(1) .rounded(cell_rounding)
} else { } else {
iced::Border::default().rounded(1) iced::Border::default().rounded(cell_rounding)
}, },
..Default::default() ..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_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]])) .on_right_press(Message::BgColorChanged([c[0], c[1], c[2], fg_color[3]]))
.interaction(iced::mouse::Interaction::Pointer); .interaction(iced::mouse::Interaction::Pointer)
row_widgets = row_widgets.push(clickable); .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,] let grid_col = iced::widget::Column::with_children(grid_rows)
.spacing(2) .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() .into()
} }
@@ -19,6 +19,7 @@ pub fn brightness_contrast_view(
1.0, 1.0,
"", "",
0, 0,
colors,
|v| Message::AdjBrightness(v), |v| Message::AdjBrightness(v),
); );
@@ -29,6 +30,7 @@ pub fn brightness_contrast_view(
1.0, 1.0,
"", "",
0, 0,
colors,
|v| Message::AdjContrast(v), |v| Message::AdjContrast(v),
); );
@@ -59,17 +61,31 @@ pub fn hsl_view(
lightness: f32, lightness: f32,
colors: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> { ) -> 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) Message::AdjHue(v)
}); });
let sat_slider = plain_slider("Saturation", saturation, -100.0..=100.0, 1.0, "", 0, |v| { let sat_slider = plain_slider(
Message::AdjSaturation(v) "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| { let light_slider = plain_slider(
Message::AdjLightness(v) "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 apply_btn = super::primary_btn("Apply", Message::AdjApply, colors);
let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors); let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors);
@@ -57,6 +57,7 @@ pub fn selection_op_view(
step, step,
"", "",
0, 0,
colors,
|v| Message::SelectionOpValue(v), |v| Message::SelectionOpValue(v),
); );
@@ -10,8 +10,8 @@ pub mod adjustments;
pub mod canvas_size; pub mod canvas_size;
pub mod confirm; pub mod confirm;
pub mod dock_profile; pub mod dock_profile;
pub mod image_size;
pub mod expand_canvas; pub mod expand_canvas;
pub mod image_size;
pub mod new_image; pub mod new_image;
pub mod save_error; pub mod save_error;
@@ -29,30 +29,94 @@ pub fn modal<'a>(
content: impl Into<Element<'a, Message>>, content: impl Into<Element<'a, Message>>,
colors: ThemeColors, colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let card = container(content).style(move |_theme| iced::widget::container::Style { let card = container(content)
background: Some(iced::Background::Color(colors.bg_panel)), .padding(1)
text_color: Some(colors.text_primary), .style(move |_theme| iced::widget::container::Style {
border: iced::Border::default() background: Some(iced::Background::Color(colors.bg_panel)),
.rounded(3) text_color: Some(colors.text_primary),
.color(colors.border_high) border: iced::Border::default()
.width(1), .rounded(4)
shadow: iced::Shadow { .color(colors.border_high)
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.45), .width(2),
offset: iced::Vector::new(0.0, 4.0), shadow: iced::Shadow {
blur_radius: 18.0, color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.55),
}, offset: iced::Vector::new(0.0, 6.0),
..Default::default() blur_radius: 24.0,
}); },
..Default::default()
});
container(card) let backdrop = container(iced::widget::Space::with_width(Length::Fill).height(Length::Fill))
.center_x(Length::Fill) .width(Length::Fill)
.center_y(Length::Fill) .height(Length::Fill)
.style(|_theme| iced::widget::container::Style { .style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( 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() ..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<Element<'a, Message>>,
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) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
@@ -89,16 +153,16 @@ fn darken(c: iced::Color, factor: f32) -> iced::Color {
/// and `colors` supplies the theme tokens. /// and `colors` supplies the theme tokens.
/// **Returns:** A styled `Element` ready for the dialog button row. /// **Returns:** A styled `Element` ready for the dialog button row.
/// **Side Effects / Dependencies:** None. /// **Side Effects / Dependencies:** None.
pub fn primary_btn( pub fn primary_btn(label: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> {
label: &str,
msg: Message,
colors: ThemeColors,
) -> Element<'static, Message> {
let l = label.to_string(); let l = label.to_string();
let c = colors; let c = colors;
button(text(l).size(12).style(move |_theme: &iced::Theme| iced::widget::text::Style { button(
color: Some(iced::Color::WHITE), text(l)
})) .size(12)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::WHITE),
}),
)
.on_press(msg) .on_press(msg)
.padding([8, 16]) .padding([8, 16])
.style(move |_theme, status| { .style(move |_theme, status| {
@@ -128,16 +192,16 @@ pub fn primary_btn(
/// and `colors` supplies the theme tokens. /// and `colors` supplies the theme tokens.
/// **Returns:** A styled `Element` ready for the dialog button row. /// **Returns:** A styled `Element` ready for the dialog button row.
/// **Side Effects / Dependencies:** None. /// **Side Effects / Dependencies:** None.
pub fn secondary_btn( pub fn secondary_btn(label: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> {
label: &str,
msg: Message,
colors: ThemeColors,
) -> Element<'static, Message> {
let l = label.to_string(); let l = label.to_string();
let c = colors; let c = colors;
button(text(l).size(12).style(move |_theme: &iced::Theme| iced::widget::text::Style { button(
color: Some(c.text_primary), text(l)
})) .size(12)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(c.text_primary),
}),
)
.on_press(msg) .on_press(msg)
.padding([8, 16]) .padding([8, 16])
.style(move |_theme, status| { .style(move |_theme, status| {
@@ -180,48 +244,53 @@ pub fn preset_btn(
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let l = label.to_string(); let l = label.to_string();
let c = colors; let c = colors;
let mut btn = button( let mut btn =
text(l) button(
.size(11) text(l)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { .size(11)
color: Some(if enabled { c.text_primary } else { c.text_disabled }), .style(move |_theme: &iced::Theme| iced::widget::text::Style {
}), color: Some(if enabled {
) c.text_primary
.padding([5, 8]) } else {
.width(Length::Fill) c.text_disabled
.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() )
} .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 { if enabled {
btn = btn.on_press(msg); btn = btn.on_press(msg);
} }
@@ -3,7 +3,9 @@
use crate::app::Message; use crate::app::Message;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider; 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}; use iced::{Element, Length};
/// A canvas preset size. /// A canvas preset size.
@@ -75,6 +77,7 @@ pub fn view(
1.0, 1.0,
"px", "px",
0, 0,
colors,
|v| Message::DialogNewImageWidth(v as u32), |v| Message::DialogNewImageWidth(v as u32),
); );
@@ -85,6 +88,7 @@ pub fn view(
1.0, 1.0,
"px", "px",
0, 0,
colors,
|v| Message::DialogNewImageHeight(v as u32), |v| Message::DialogNewImageHeight(v as u32),
); );
@@ -96,7 +100,8 @@ pub fn view(
let label = preset.name; let label = preset.name;
let w = preset.width; let w = preset.width;
let h = preset.height; 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); presets_col = presets_col.push(preset_btn);
} }
@@ -135,7 +140,8 @@ pub fn view(
] ]
.spacing(6) .spacing(6)
.padding(16) .padding(16)
.width(400); .width(400)
.height(560);
super::modal(dialog, colors) super::modal_sized(dialog, colors, 400.0, 560.0)
} }
@@ -182,6 +182,8 @@ pub fn panel_body<'a>(
app.tool_state.brush_size, app.tool_state.brush_size,
app.tool_state.brush_opacity, app.tool_state.brush_opacity,
app.tool_state.brush_hardness, app.tool_state.brush_hardness,
app.brush_category,
&app.tool_state.imported_brushes,
colors, colors,
), ),
PaneType::Filters => crate::panels::filters::view( PaneType::Filters => crate::panels::filters::view(
@@ -199,6 +201,7 @@ pub fn panel_body<'a>(
&app.bg_color, &app.bg_color,
&app.recent_colors, &app.recent_colors,
app.color_tab, app.color_tab,
colors,
), ),
PaneType::Properties => { PaneType::Properties => {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
@@ -281,13 +284,7 @@ pub fn panel_body<'a>(
PaneType::ToolSettings => { PaneType::ToolSettings => {
let fg = app.fg_color; let fg = app.fg_color;
let draft = app.documents[app.active_doc].text_draft.as_ref(); let draft = app.documents[app.active_doc].text_draft.as_ref();
crate::panels::tool_settings::view( crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
&app.tool_state,
&app.settings,
fg,
draft,
colors,
)
} }
PaneType::CustomShapes => { PaneType::CustomShapes => {
let selected_index = match app.tool_state.active_tool { let selected_index = match app.tool_state.active_tool {
@@ -295,10 +292,7 @@ pub fn panel_body<'a>(
_ => None, _ => None,
}; };
crate::panels::custom_shapes::view( crate::panels::custom_shapes::view(
app.documents[app.active_doc] app.documents[app.active_doc].engine.shape_catalog.all(),
.engine
.shape_catalog
.all(),
selected_index, selected_index,
colors, colors,
) )
@@ -3,5 +3,6 @@
pub mod cli; pub mod cli;
pub mod raster; pub mod raster;
pub mod selection; pub mod selection;
pub mod theme;
pub mod vector_edit; pub mod vector_edit;
pub mod widgets; pub mod widgets;
@@ -11,144 +11,201 @@
use crate::app::Message; use crate::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::BrushStyle; use hcie_engine_api::BrushStyle;
use iced::widget::{ use iced::widget::{button, column, container, horizontal_rule, row, scrollable, svg, text};
button, column, container, horizontal_rule, row, scrollable, slider, svg, text,
};
use iced::{Element, Length}; use iced::{Element, Length};
use std::sync::OnceLock; use std::sync::OnceLock;
/// A brush preset entry with category. /// One built-in brush style exposed by the engine.
#[allow(dead_code)] struct BrushStyleEntry {
struct BrushPreset {
name: &'static str, name: &'static str,
style: BrushStyle, style: BrushStyle,
category: BrushCategory, category: BrushCategory,
} }
/// User-selectable category used to filter brush styles and runtime presets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] pub enum BrushCategory {
enum BrushCategory {
All, All,
Drawing, Drawing,
Painting, Painting,
Effects, Effects,
Custom,
Imported,
} }
const BRUSH_PRESETS: &[BrushPreset] = &[ const BRUSH_STYLES: &[BrushStyleEntry] = &[
// Drawing BrushStyleEntry {
BrushPreset {
name: "Default", name: "Default",
style: BrushStyle::Default, style: BrushStyle::Default,
category: BrushCategory::Drawing, 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", name: "Pencil",
style: BrushStyle::Pencil, style: BrushStyle::Pencil,
category: BrushCategory::Drawing, category: BrushCategory::Drawing,
}, },
BrushPreset { BrushStyleEntry {
name: "Pen", name: "Pen",
style: BrushStyle::Pen, style: BrushStyle::Pen,
category: BrushCategory::Drawing, category: BrushCategory::Drawing,
}, },
BrushPreset { BrushStyleEntry {
name: "Ink Pen", name: "Ink Pen",
style: BrushStyle::InkPen, style: BrushStyle::InkPen,
category: BrushCategory::Drawing, category: BrushCategory::Drawing,
}, },
BrushPreset { BrushStyleEntry {
name: "Calligraphy", name: "Calligraphy",
style: BrushStyle::Calligraphy, style: BrushStyle::Calligraphy,
category: BrushCategory::Drawing, category: BrushCategory::Drawing,
}, },
BrushPreset { BrushStyleEntry {
name: "Sketch",
style: BrushStyle::Sketch,
category: BrushCategory::Drawing,
},
BrushPreset {
name: "Marker", name: "Marker",
style: BrushStyle::Marker, style: BrushStyle::Marker,
category: BrushCategory::Drawing, category: BrushCategory::Drawing,
}, },
BrushPreset { BrushStyleEntry {
name: "Sketch",
style: BrushStyle::Sketch,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Hatch", name: "Hatch",
style: BrushStyle::Hatch, style: BrushStyle::Hatch,
category: BrushCategory::Drawing, category: BrushCategory::Drawing,
}, },
// Painting BrushStyleEntry {
BrushPreset { name: "Star",
style: BrushStyle::Star,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Oil", name: "Oil",
style: BrushStyle::Oil, style: BrushStyle::Oil,
category: BrushCategory::Painting, category: BrushCategory::Painting,
}, },
BrushPreset { BrushStyleEntry {
name: "Charcoal", name: "Charcoal",
style: BrushStyle::Charcoal, style: BrushStyle::Charcoal,
category: BrushCategory::Painting, category: BrushCategory::Painting,
}, },
BrushPreset { BrushStyleEntry {
name: "Wood",
style: BrushStyle::Wood,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Watercolor", name: "Watercolor",
style: BrushStyle::Watercolor, style: BrushStyle::Watercolor,
category: BrushCategory::Painting, category: BrushCategory::Painting,
}, },
BrushPreset { BrushStyleEntry {
name: "Crayon",
style: BrushStyle::Crayon,
category: BrushCategory::Painting,
},
BrushPreset {
name: "Airbrush", name: "Airbrush",
style: BrushStyle::Airbrush, style: BrushStyle::Airbrush,
category: BrushCategory::Painting, category: BrushCategory::Painting,
}, },
BrushPreset { BrushStyleEntry {
name: "Spray", name: "Crayon",
style: BrushStyle::Spray, style: BrushStyle::Crayon,
category: BrushCategory::Painting, category: BrushCategory::Painting,
}, },
BrushPreset { BrushStyleEntry {
name: "Soft Round", name: "Wet Paint",
style: BrushStyle::SoftRound, style: BrushStyle::WetPaint,
category: BrushCategory::Painting, category: BrushCategory::Painting,
}, },
BrushPreset { BrushStyleEntry {
name: "Hard Round", name: "Bristle",
style: BrushStyle::HardRound, style: BrushStyle::Bristle,
category: BrushCategory::Painting, category: BrushCategory::Painting,
}, },
// Effects BrushStyleEntry {
BrushPreset { name: "Mixer",
name: "Star", style: BrushStyle::Mixer,
style: BrushStyle::Star, category: BrushCategory::Painting,
category: BrushCategory::Effects,
}, },
BrushPreset { BrushStyleEntry {
name: "Blender",
style: BrushStyle::Blender,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Noise", name: "Noise",
style: BrushStyle::Noise, style: BrushStyle::Noise,
category: BrushCategory::Effects, category: BrushCategory::Effects,
}, },
BrushPreset { BrushStyleEntry {
name: "Texture", name: "Texture",
style: BrushStyle::Texture, style: BrushStyle::Texture,
category: BrushCategory::Effects, category: BrushCategory::Effects,
}, },
BrushPreset { BrushStyleEntry {
name: "Glow", name: "Spray",
style: BrushStyle::Glow, style: BrushStyle::Spray,
category: BrushCategory::Effects, category: BrushCategory::Effects,
}, },
BrushPreset { BrushStyleEntry {
name: "Leaf", name: "Leaf",
style: BrushStyle::Leaf, style: BrushStyle::Leaf,
category: BrushCategory::Effects, 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", name: "Clouds",
style: BrushStyle::Clouds, style: BrushStyle::Clouds,
category: BrushCategory::Effects, 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). /// Cached brush preview images: (style_index, pixels).
@@ -157,6 +214,64 @@ static BRUSH_PREVIEW_CACHE: OnceLock<Vec<(usize, Vec<u8>)>> = OnceLock::new();
/// Thumbnail size in pixels. /// Thumbnail size in pixels.
const THUMB_SIZE: u32 = 64; 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. /// Generate a brush stroke preview as RGBA pixels.
/// ///
/// Draws a sine-wave stroke with varying thickness to show the brush characteristics. /// Draws a sine-wave stroke with varying thickness to show the brush characteristics.
@@ -295,6 +410,8 @@ pub fn view(
current_size: f32, current_size: f32,
current_opacity: f32, current_opacity: f32,
current_hardness: f32, current_hardness: f32,
active_category: BrushCategory,
imported_presets: &[hcie_engine_api::BrushPreset],
colors: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
// Category tabs // Category tabs
@@ -303,11 +420,13 @@ pub fn view(
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"), (BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
(BrushCategory::Painting, "Painting", "icons/brush.svg"), (BrushCategory::Painting, "Painting", "icons/brush.svg"),
(BrushCategory::Effects, "Effects", "icons/glow.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); let mut tabs = row![].spacing(2);
for (cat, label, icon_path) in categories { for (cat, label, icon_path) in categories {
let active = cat == BrushCategory::All; let active = cat == active_category;
let icon = svg(svg::Handle::from_path( let icon = svg(svg::Handle::from_path(
std::path::Path::new("hcie-iced-app/assets").join(icon_path), std::path::Path::new("hcie-iced-app/assets").join(icon_path),
)) ))
@@ -320,39 +439,42 @@ pub fn view(
colors.text_secondary colors.text_secondary
}), }),
}); });
let btn = button(icon).padding([3, 6]).style( let btn = button(icon)
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { .on_press(Message::BrushCategorySelected(cat))
iced::widget::button::Status::Hovered => iced::widget::button::Style { .padding([3, 6])
background: Some(iced::Background::Color(colors.bg_hover)), .style(
text_color: colors.text_primary, move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
border: iced::Border::default(), iced::widget::button::Status::Hovered => iced::widget::button::Style {
..Default::default() background: Some(iced::Background::Color(colors.bg_hover)),
}, text_color: colors.text_primary,
_ => iced::widget::button::Style { border: iced::Border::default(),
background: Some(iced::Background::Color(if active { ..Default::default()
colors.bg_active
} else {
iced::Color::TRANSPARENT
})),
text_color: if active {
colors.accent
} else {
colors.text_secondary
}, },
border: iced::Border::default() _ => iced::widget::button::Style {
.color(if active { background: Some(iced::Background::Color(if active {
colors.bg_active
} else {
iced::Color::TRANSPARENT
})),
text_color: if active {
colors.accent colors.accent
} else { } else {
colors.border_low colors.text_secondary
}) },
.width(if active { 1 } else { 0 }), border: iced::Border::default()
..Default::default() .color(if active {
colors.accent
} else {
colors.border_low
})
.width(if active { 1 } else { 0 }),
..Default::default()
},
}, },
}, );
);
tabs = tabs.push(styles::balloon_tooltip( tabs = tabs.push(styles::balloon_tooltip(
btn, btn,
format!("{label} (category filtering is not available yet)"), label,
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
colors, colors,
)); ));
@@ -362,7 +484,12 @@ pub fn view(
let mut grid_rows = column![].spacing(4); let mut grid_rows = column![].spacing(4);
let mut current_row = row![].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 is_selected = preset.style == current_style;
let preview = get_brush_preview(preset.style); let preview = get_brush_preview(preset.style);
let c = colors; let c = colors;
@@ -373,21 +500,7 @@ pub fn view(
.height(56) .height(56)
.center_x(48) .center_x(48)
.center_y(48) .center_y(48)
.style(move |_theme| { .style(move |_theme| styles::raised_card(c, is_selected));
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()
}
}
});
let label = container(text(preset.name).size(9)) let label = container(text(preset.name).size(9))
.width(Length::Fill) .width(Length::Fill)
@@ -421,28 +534,111 @@ pub fn view(
current_row = current_row.push(clickable); 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); grid_rows = grid_rows.push(current_row);
current_row = row![].spacing(4); 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 // Sliders
let size_slider = slider(1.0..=200.0, current_size, |v| Message::BrushSizeChanged(v)) let size_slider = plain_slider(
.step(1.0) "Size",
.width(Length::Fill); current_size,
1.0..=200.0,
let opacity_slider = slider(0.0..=1.0, current_opacity, |v| { 1.0,
Message::BrushOpacityChanged(v) "px",
}) 0,
.step(0.01) colors,
.width(Length::Fill); Message::BrushSizeChanged,
);
let hardness_slider = slider(0.0..=1.0, current_hardness, |v| { let opacity_slider = plain_slider(
Message::BrushHardnessChanged(v) "Opacity",
}) current_opacity,
.step(0.01) 0.0..=1.0,
.width(Length::Fill); 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 // Live preview — show current brush tip
let preview_handle = get_brush_preview(current_style); let preview_handle = get_brush_preview(current_style);
@@ -451,17 +647,13 @@ pub fn view(
.height(80) .height(80)
.center_x(Length::Fill) .center_x(Length::Fill)
.center_y(Length::Fill) .center_y(Length::Fill)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| styles::recessed_control(colors));
background: Some(iced::Background::Color(colors.bg_active)),
border: iced::Border::default().color(colors.accent).width(1),
..Default::default()
});
// Panel title is in the dock tab — no duplicate inside the panel. // Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![ let panel = column![
tabs, tabs,
horizontal_rule(1), horizontal_rule(1),
scrollable(grid_rows).height(Length::Fill), scrollable(column![grid_rows, imported_rows].spacing(6)).height(Length::Fill),
horizontal_rule(1), horizontal_rule(1),
row![button(text("Import ABR").size(10)) row![button(text("Import ABR").size(10))
.on_press(Message::BrushImportAbr) .on_press(Message::BrushImportAbr)
@@ -487,11 +679,8 @@ pub fn view(
.spacing(4), .spacing(4),
text("Preview").size(10), text("Preview").size(10),
live_preview, live_preview,
text(format!("Size: {:.0}", current_size)).size(10),
size_slider, size_slider,
text(format!("Opacity: {:.0}%", current_opacity * 100.0)).size(10),
opacity_slider, opacity_slider,
text(format!("Hardness: {:.0}%", current_hardness * 100.0)).size(10),
hardness_slider, hardness_slider,
] ]
.spacing(2) .spacing(2)
@@ -503,3 +692,33 @@ pub fn view(
.style(move |_theme| styles::panel_background(colors)) .style(move |_theme| styles::panel_background(colors))
.into() .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
));
}
}
@@ -21,7 +21,7 @@ use crate::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::Tool; 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 iced::{Element, Length};
use std::borrow::Cow; use std::borrow::Cow;
@@ -53,14 +53,6 @@ pub fn view(
.into(); .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)) let refresh_btn = button(text("Refresh").size(9).color(colors.text_primary))
.on_press(Message::RefreshCustomShapes) .on_press(Message::RefreshCustomShapes)
.padding([2, 6]) .padding([2, 6])
@@ -93,7 +85,14 @@ pub fn view(
column![ column![
header, header,
text(shape_dir_hint()).size(9).color(colors.text_secondary), 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) .spacing(4)
.width(Length::Fill), .width(Length::Fill),
@@ -105,6 +104,55 @@ pub fn view(
.into() .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<usize>,
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. /// Render one shape card with a small SVG preview and label.
fn shape_card<'a>( fn shape_card<'a>(
index: usize, index: usize,
@@ -118,45 +166,36 @@ fn shape_card<'a>(
let handle = svg::Handle::from_memory(svg_bytes); let handle = svg::Handle::from_memory(svg_bytes);
let preview = iced::widget::Svg::new(handle) let preview = iced::widget::Svg::new(handle)
.width(Length::Fixed(40.0)) .width(Length::Fixed(36.0))
.height(Length::Fixed(40.0)) .height(Length::Fixed(36.0))
.style(move |_theme, _status| svg::Style { .style(move |_theme, _status| svg::Style {
color: Some(colors.text_primary), color: Some(colors.text_primary),
}); });
// Label derived from the shape kind, matching the catalog's capitalization. // 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![ let content = column![
container(preview) container(preview)
.width(Length::Fill) .width(Length::Fill)
.center_x(Length::Fill), .center_x(Length::Fill),
container(label) container(label).width(Length::Fill).center_x(Length::Fill),
.width(Length::Fill)
.center_x(Length::Fill),
] ]
.spacing(2) .spacing(2)
.width(Length::Fill); .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( button(
container(content) container(content)
.width(Length::Fill) .width(Length::Fill)
.padding(4) .padding(4)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| styles::raised_card(colors, is_selected)),
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()
}),
) )
.on_press(Message::ToolSelected(Tool::CustomShape(index as u32))) .on_press(Message::ToolSelected(Tool::CustomShape(index as u32)))
.padding(0) .padding(0)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fixed(70.0))
.style(move |_theme, _status| iced::widget::button::Style { .style(move |_theme, _status| iced::widget::button::Style {
background: None, background: None,
..Default::default() ..Default::default()
@@ -164,6 +203,18 @@ fn shape_card<'a>(
.into() .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. /// Return the user-facing path hint for custom shape files.
fn shape_dir_hint() -> String { fn shape_dir_hint() -> String {
dirs::data_local_dir() dirs::data_local_dir()
@@ -49,7 +49,7 @@ fn float_slider(
min: f64, min: f64,
max: f64, max: f64,
step: f64, step: f64,
_colors: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let key_owned = key.to_string(); let key_owned = key.to_string();
let filter_owned = filter_id.to_string(); let filter_owned = filter_id.to_string();
@@ -62,12 +62,10 @@ fn float_slider(
step_f, step_f,
"", "",
1, 1,
colors,
move |v| { move |v| {
let rounded = (v as f64 / step).round() * step; let rounded = (v as f64 / step).round() * step;
Message::FilterParamChanged( Message::FilterParamChanged(filter_owned.clone(), json!({ key_owned.clone(): rounded }))
filter_owned.clone(),
json!({ key_owned.clone(): rounded }),
)
}, },
) )
} }
@@ -80,7 +78,7 @@ fn int_slider(
value: u64, value: u64,
min: u64, min: u64,
max: u64, max: u64,
_colors: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let key_owned = key.to_string(); let key_owned = key.to_string();
let filter_owned = filter_id.to_string(); let filter_owned = filter_id.to_string();
@@ -92,6 +90,7 @@ fn int_slider(
1.0, 1.0,
"", "",
0, 0,
colors,
move |v| { move |v| {
Message::FilterParamChanged( Message::FilterParamChanged(
filter_owned.clone(), filter_owned.clone(),
@@ -996,8 +995,5 @@ pub fn view(
} }
}; };
container(content) container(content).width(Length::Fill).padding(4).into()
.width(Length::Fill)
.padding(4)
.into()
} }
@@ -10,10 +10,10 @@ use crate::color_picker::ColorPickerTarget;
use crate::panels::styles; use crate::panels::styles;
use crate::panels::typography::{BODY, SECTION}; use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape}; use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape};
use iced::widget::{ use iced::widget::{
button, checkbox, column, container, pick_list, row, scrollable, slider, text, text_input, button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space,
Space,
}; };
use iced::{Element, Length}; use iced::{Element, Length};
@@ -64,17 +64,9 @@ fn labeled_slider<'a>(
value: f32, value: f32,
range: std::ops::RangeInclusive<f32>, range: std::ops::RangeInclusive<f32>,
on_change: fn(f32) -> Message, on_change: fn(f32) -> Message,
colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
row![ plain_slider(label, value, range, 0.5, "", 1, colors, on_change)
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()
} }
/// Build the geometry panel. /// Build the geometry panel.
@@ -127,9 +119,8 @@ pub fn view(
.on_press(Message::VectorShapeDelete) .on_press(Message::VectorShapeDelete)
.padding([2, 6]), .padding([2, 6]),
); );
content = content.push( content =
container(header.spacing(4).width(Length::Fill)).padding([2, 0]), content.push(container(header.spacing(4).width(Length::Fill)).padding([2, 0]));
);
} }
// Shape list — rendered front-to-back so the topmost (last index) // Shape list — rendered front-to-back so the topmost (last index)
@@ -334,24 +325,28 @@ pub fn view(
x1, x1,
-2000.0..=5000.0, -2000.0..=5000.0,
Message::GeometryBoundsX1, Message::GeometryBoundsX1,
colors,
)); ));
content = content.push(labeled_slider( content = content.push(labeled_slider(
"Y1", "Y1",
y1, y1,
-2000.0..=5000.0, -2000.0..=5000.0,
Message::GeometryBoundsY1, Message::GeometryBoundsY1,
colors,
)); ));
content = content.push(labeled_slider( content = content.push(labeled_slider(
"X2", "X2",
x2, x2,
-2000.0..=5000.0, -2000.0..=5000.0,
Message::GeometryBoundsX2, Message::GeometryBoundsX2,
colors,
)); ));
content = content.push(labeled_slider( content = content.push(labeled_slider(
"Y2", "Y2",
y2, y2,
-2000.0..=5000.0, -2000.0..=5000.0,
Message::GeometryBoundsY2, Message::GeometryBoundsY2,
colors,
)); ));
// Size display // Size display
@@ -406,6 +401,7 @@ pub fn view(
cur_opacity, cur_opacity,
0.0..=1.0, 0.0..=1.0,
Message::GeometryOpacityChanged, Message::GeometryOpacityChanged,
colors,
)); ));
// Hardness slider // Hardness slider
@@ -415,6 +411,7 @@ pub fn view(
cur_hardness, cur_hardness,
0.0..=1.0, 0.0..=1.0,
Message::GeometryHardnessChanged, Message::GeometryHardnessChanged,
colors,
)); ));
// Line caps (for Line shapes only) // Line caps (for Line shapes only)
@@ -36,11 +36,7 @@ pub fn view<'a>(
label label
}; };
let item_style = if is_current { let item_style = styles::raised_card(colors, is_current);
styles::active_item_bg(colors)
} else {
styles::inactive_item_bg(colors)
};
let item = container(label) let item = container(label)
.width(Length::Fill) .width(Length::Fill)
@@ -11,7 +11,7 @@ use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::LayerStyle; use hcie_engine_api::LayerStyle;
use iced::widget::{ 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}; use iced::{Element, Length};
@@ -346,7 +346,7 @@ pub fn view<'a>(
let ox = drag_offset.0; let ox = drag_offset.0;
let oy = drag_offset.1; 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)) .width(Length::Fixed(580.0))
.height(Length::Fixed(420.0)) .height(Length::Fixed(420.0))
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
@@ -362,6 +362,11 @@ pub fn view<'a>(
}, },
..Default::default() ..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 // Apply horizontal offset using Space and doubling technique
let dialog_row = if ox >= 0.0 { 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)),] 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) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.center_x(Length::Fill) .center_x(Length::Fill)
.center_y(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. /// Build parameter controls for a specific style.
@@ -855,25 +874,10 @@ fn slider_row<'a>(
value: f32, value: f32,
range: std::ops::RangeInclusive<f32>, range: std::ops::RangeInclusive<f32>,
on_change: impl Fn(f32) -> Message + 'static, on_change: impl Fn(f32) -> Message + 'static,
_colors: ThemeColors, colors: ThemeColors,
) -> Element<'a, Message> { ) -> 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 }; let step = if *range.end() <= 1.0 { 0.01 } else { 1.0 };
crate::widgets::plain_slider::plain_slider(label, value, range, step, "", 1, colors, on_change)
row![
label_text,
slider(range, value, on_change)
.step(step)
.width(Length::Fill),
value_label,
]
.spacing(3)
.align_y(iced::Alignment::Center)
.into()
} }
/// Docked Layer Styles panel — lists effects with enable checkboxes. /// 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 /// **Purpose:** A simplified, always-visible panel showing all layer effects
/// with toggle checkboxes. This is the docked counterpart to the floating /// with toggle checkboxes. This is the docked counterpart to the floating
/// `view()` dialog; it provides quick enable/disable without full parameter editing. /// `view()` dialog; it provides quick enable/disable without full parameter editing.
pub fn panel_view<'a>( pub fn panel_view<'a>(styles_list: Vec<LayerStyle>, colors: ThemeColors) -> Element<'a, Message> {
styles_list: Vec<LayerStyle>,
colors: ThemeColors,
) -> Element<'a, Message> {
let mut effects_list = column![].spacing(1); let mut effects_list = column![].spacing(1);
for (i, &(disc, label, color_arr)) in STYLE_DEFS.iter().enumerate() { 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)) .on_toggle(move |_| Message::LayerStyleToggle(i as u32))
.size(11); .size(11);
let label_text = text(label) let label_text =
.size(11) text(label)
.width(Length::Fill) .size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { .width(Length::Fill)
color: Some(if enabled { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
badge_color color: Some(if enabled {
} else { badge_color
colors.text_secondary } else {
}), colors.text_secondary
}); }),
});
let item_row = row![toggle, label_text] let item_row = row![toggle, label_text]
.spacing(4) .spacing(4)
@@ -228,6 +228,7 @@ pub fn view<'a>(
0.01, 0.01,
"%", "%",
0, 0,
colors,
move |v| Message::LayerSetOpacity(active_layer_id, v), move |v| Message::LayerSetOpacity(active_layer_id, v),
); );
@@ -291,11 +292,18 @@ pub fn view<'a>(
}; };
// Visibility toggle (eye icon) // 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 { let vis_color = if info.visible {
colors.text_primary colors.text_primary
} else { } 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| { let vis_btn = button(text(vis_char).size(11).style(move |_theme: &iced::Theme| {
iced::widget::text::Style { iced::widget::text::Style {
@@ -17,7 +17,7 @@ use crate::dock::state::{DockState, PaneType};
use crate::panels::typography; use crate::panels::typography;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::Tool; 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}; use iced::{Element, Length};
/// Identifies one implemented menu operation independently of menu position or label. /// Identifies one implemented menu operation independently of menu position or label.
@@ -341,7 +341,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste), MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste),
MenuItem::command("Clear", MenuCommand::ClearPixels), MenuItem::command("Clear", MenuCommand::ClearPixels),
MenuItem::separator(), 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::separator(),
MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill), MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill),
MenuItem::command("Stroke...", MenuCommand::Stroke), MenuItem::command("Stroke...", MenuCommand::Stroke),
@@ -349,7 +353,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Content-Aware Scale"), MenuItem::new("Content-Aware Scale"),
MenuItem::new("Puppet Warp"), MenuItem::new("Puppet Warp"),
MenuItem::new("Perspective 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::submenu("Transform"),
MenuItem::new("Auto-Align"), MenuItem::new("Auto-Align"),
MenuItem::new("Auto-Blend"), MenuItem::new("Auto-Blend"),
@@ -359,7 +367,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::separator(), MenuItem::separator(),
MenuItem::submenu("Define New"), MenuItem::submenu("Define New"),
MenuItem::new("Preset Manager..."), 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..."), MenuItem::new("Local Storage..."),
], ],
}, },
@@ -367,28 +379,72 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuDef { MenuDef {
label: "Tools".to_string(), label: "Tools".to_string(),
items: vec![ items: vec![
MenuItem::command_with_shortcut("Eyedropper", "I", MenuCommand::SelectTool(Tool::Eyedropper)), MenuItem::command_with_shortcut(
MenuItem::command_with_shortcut("Pencil / Pen", "P", MenuCommand::SelectTool(Tool::Pen)), "Eyedropper",
MenuItem::command_with_shortcut("Art Brush", "B", MenuCommand::SelectTool(Tool::Brush)), "I",
MenuItem::command_with_shortcut("Eraser", "E", MenuCommand::SelectTool(Tool::Eraser)), 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("Spray", MenuCommand::SelectTool(Tool::Spray)),
MenuItem::command_with_shortcut("Flood Fill", "F", MenuCommand::SelectTool(Tool::FloodFill)), MenuItem::command_with_shortcut(
MenuItem::command_with_shortcut("Magic Wand", "W", MenuCommand::SelectTool(Tool::MagicWand)), "Flood Fill",
MenuItem::command_with_shortcut("Rect Selection", "M", MenuCommand::SelectTool(Tool::Select)), "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_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("Move", "V", MenuCommand::SelectTool(Tool::Move)),
MenuItem::command_with_shortcut("Crop", "C", MenuCommand::SelectTool(Tool::Crop)), MenuItem::command_with_shortcut("Crop", "C", MenuCommand::SelectTool(Tool::Crop)),
MenuItem::command_with_shortcut("Text", "T", MenuCommand::SelectTool(Tool::Text)), 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::separator(),
MenuItem::disabled("Retouch"), MenuItem::disabled("Retouch"),
MenuItem::command("Red-eye Removal", MenuCommand::SelectTool(Tool::RedEyeRemoval)), MenuItem::command(
MenuItem::command("Spot Removal Patch", MenuCommand::SelectTool(Tool::SpotRemoval)), "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::command("Smart Patch", MenuCommand::SelectTool(Tool::SmartPatch)),
MenuItem::separator(), MenuItem::separator(),
MenuItem::disabled("AI"), 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("Smart Select", MenuCommand::SelectTool(Tool::SmartSelect)),
MenuItem::command("Vision Select", MenuCommand::SelectTool(Tool::VisionSelect)), MenuItem::command("Vision Select", MenuCommand::SelectTool(Tool::VisionSelect)),
], ],
@@ -460,11 +516,20 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::with_shortcut("Group Layers", "Ctrl+G"), MenuItem::with_shortcut("Group Layers", "Ctrl+G"),
MenuItem::submenu("Arrange"), MenuItem::submenu("Arrange"),
MenuItem::command("Align Left", MenuCommand::AlignLayer("left".to_string())), 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 Right", MenuCommand::AlignLayer("right".to_string())),
MenuItem::command("Align Top", MenuCommand::AlignLayer("top".to_string())), MenuItem::command("Align Top", MenuCommand::AlignLayer("top".to_string())),
MenuItem::command("Align Middle", MenuCommand::AlignLayer("middle".to_string())), MenuItem::command(
MenuItem::command("Align Bottom", MenuCommand::AlignLayer("bottom".to_string())), "Align Middle",
MenuCommand::AlignLayer("middle".to_string()),
),
MenuItem::command(
"Align Bottom",
MenuCommand::AlignLayer("bottom".to_string()),
),
MenuItem::submenu("Combine Shapes"), MenuItem::submenu("Combine Shapes"),
MenuItem::submenu("Animation"), MenuItem::submenu("Animation"),
MenuItem::separator(), MenuItem::separator(),
@@ -557,9 +622,18 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
// ── Blur ── // ── Blur ──
MenuItem::disabled("Blur"), MenuItem::disabled("Blur"),
MenuItem::command("Box Blur", MenuCommand::FilterDistort("box_blur".into())), MenuItem::command("Box Blur", MenuCommand::FilterDistort("box_blur".into())),
MenuItem::command("Gaussian Blur", MenuCommand::FilterDistort("gaussian_blur".into())), MenuItem::command(
MenuItem::command("Motion Blur", MenuCommand::FilterDistort("motion_blur".into())), "Gaussian Blur",
MenuItem::command("Unsharp Mask", MenuCommand::FilterRestore("unsharp_mask".into())), 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(), MenuItem::separator(),
// ── Distort ── // ── Distort ──
MenuItem::disabled("Distort"), MenuItem::disabled("Distort"),
@@ -571,11 +645,17 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::command("Add Noise", MenuCommand::FilterNoise("add_noise".into())), MenuItem::command("Add Noise", MenuCommand::FilterNoise("add_noise".into())),
MenuItem::command("Median Filter", MenuCommand::FilterRestore("median".into())), MenuItem::command("Median Filter", MenuCommand::FilterRestore("median".into())),
MenuItem::command("Dehaze", MenuCommand::FilterRestore("dehaze".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(), MenuItem::separator(),
// ── Pixelate ── // ── Pixelate ──
MenuItem::disabled("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::command("Mosaic", MenuCommand::FilterPixelate("mosaic".into())),
MenuItem::separator(), MenuItem::separator(),
// ── Render ── // ── Render ──
@@ -585,7 +665,10 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
// ── Procedural Textures (flat-grouped; ICED menus are single-level) ── // ── Procedural Textures (flat-grouped; ICED menus are single-level) ──
MenuItem::disabled("Procedural Textures"), MenuItem::disabled("Procedural Textures"),
MenuItem::disabled("Natural"), 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("Grass", MenuCommand::FilterTexture("texture_grass".into())),
MenuItem::command("Sand", MenuCommand::FilterTexture("texture_sand".into())), MenuItem::command("Sand", MenuCommand::FilterTexture("texture_sand".into())),
MenuItem::command("Dirt", MenuCommand::FilterTexture("texture_dirt".into())), MenuItem::command("Dirt", MenuCommand::FilterTexture("texture_dirt".into())),
@@ -594,34 +677,61 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::command("Stone", MenuCommand::FilterTexture("texture_stone".into())), MenuItem::command("Stone", MenuCommand::FilterTexture("texture_stone".into())),
MenuItem::separator(), MenuItem::separator(),
MenuItem::disabled("Architecture"), MenuItem::disabled("Architecture"),
MenuItem::command("Bricks", MenuCommand::FilterTexture("texture_bricks".into())), MenuItem::command(
"Bricks",
MenuCommand::FilterTexture("texture_bricks".into()),
),
MenuItem::separator(), MenuItem::separator(),
MenuItem::disabled("Wood"), MenuItem::disabled("Wood"),
MenuItem::command("Oak", MenuCommand::FilterTexture("texture_wood_oak".into())), MenuItem::command("Oak", MenuCommand::FilterTexture("texture_wood_oak".into())),
MenuItem::command("Pine", MenuCommand::FilterTexture("texture_wood_pine".into())), MenuItem::command(
MenuItem::command("Dark", MenuCommand::FilterTexture("texture_wood_dark".into())), "Pine",
MenuCommand::FilterTexture("texture_wood_pine".into()),
),
MenuItem::command(
"Dark",
MenuCommand::FilterTexture("texture_wood_dark".into()),
),
MenuItem::separator(), MenuItem::separator(),
MenuItem::disabled("Metal"), MenuItem::disabled("Metal"),
MenuItem::command("Steel", MenuCommand::FilterTexture("texture_metal_steel".into())), MenuItem::command(
MenuItem::command("Gold", MenuCommand::FilterTexture("texture_metal_gold".into())), "Steel",
MenuItem::command("Copper", MenuCommand::FilterTexture("texture_metal_copper".into())), 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::separator(),
MenuItem::disabled("Material"), MenuItem::disabled("Material"),
MenuItem::command("Linen", MenuCommand::FilterTexture("texture_linen".into())), MenuItem::command("Linen", MenuCommand::FilterTexture("texture_linen".into())),
MenuItem::command("Glass", MenuCommand::FilterTexture("texture_glass".into())), MenuItem::command("Glass", MenuCommand::FilterTexture("texture_glass".into())),
MenuItem::separator(), MenuItem::separator(),
MenuItem::disabled("Effect"), MenuItem::disabled("Effect"),
MenuItem::command("Sky Replacement", MenuCommand::FilterTexture("sky_replacement".into())), MenuItem::command(
"Sky Replacement",
MenuCommand::FilterTexture("sky_replacement".into()),
),
MenuItem::separator(), MenuItem::separator(),
// ── Sharpen / Restore ── // ── Sharpen / Restore ──
MenuItem::disabled("Sharpen / Restore"), MenuItem::disabled("Sharpen / Restore"),
MenuItem::command("Sharpen", MenuCommand::FilterRestore("sharpen".into())), 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(), MenuItem::separator(),
// ── Stylize ── // ── Stylize ──
MenuItem::disabled("Stylize"), MenuItem::disabled("Stylize"),
MenuItem::command("Emboss", MenuCommand::FilterStylize("emboss".into())), 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("Oil Paint", MenuCommand::FilterStylize("oil_paint".into())),
MenuItem::command("Posterize", MenuCommand::FilterStylize("posterize".into())), MenuItem::command("Posterize", MenuCommand::FilterStylize("posterize".into())),
MenuItem::command("Threshold", MenuCommand::FilterStylize("threshold".into())), MenuItem::command("Threshold", MenuCommand::FilterStylize("threshold".into())),
@@ -631,11 +741,23 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::command("Levels", MenuCommand::FilterRender("levels".into())), MenuItem::command("Levels", MenuCommand::FilterRender("levels".into())),
MenuItem::command("Vibrance", MenuCommand::FilterRender("vibrance".into())), MenuItem::command("Vibrance", MenuCommand::FilterRender("vibrance".into())),
MenuItem::command("Exposure", MenuCommand::FilterRender("exposure".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("Black & White", MenuCommand::FilterStylize("bw".into())),
MenuItem::command("Gradient Map", MenuCommand::FilterRender("gradient_map".into())), MenuItem::command(
MenuItem::command("Channel Mixer", MenuCommand::FilterRender("channel_mixer".into())), "Gradient Map",
MenuItem::command("Selective Color", MenuCommand::FilterRender("selective_color".into())), 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(), MenuItem::separator(),
// ── Fourier ── // ── Fourier ──
MenuItem::disabled("Fourier"), MenuItem::disabled("Fourier"),
@@ -781,11 +903,20 @@ pub fn dropdown_overlay(
colors: ThemeColors, colors: ThemeColors,
dock: Option<&DockState>, dock: Option<&DockState>,
viewport_width: f32, viewport_width: f32,
viewport_height: f32,
) -> Option<Element<'static, Message>> { ) -> Option<Element<'static, Message>> {
let menu_idx = active_menu?; let menu_idx = active_menu?;
let menus = all_menus(recent_files); let menus = all_menus(recent_files);
let menu_items = menus.get(menu_idx)?.items.clone(); 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<Element<'static, Message>> = Vec::new(); let mut items: Vec<Element<'static, Message>> = Vec::new();
let mut item_idx = 0; let mut item_idx = 0;
@@ -888,10 +1019,9 @@ pub fn dropdown_overlay(
} }
let col = column(items).spacing(0); let col = column(items).spacing(0);
let surface = container(Space::with_width(260).height(menu_height))
// Photopea-style dropdown: light gray background with border and shadow
let dropdown = container(col)
.width(260) .width(260)
.height(menu_height)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.menu_bg)), background: Some(iced::Background::Color(colors.menu_bg)),
border: iced::Border::default().color(colors.border_high).width(1), border: iced::Border::default().color(colors.border_high).width(1),
@@ -902,6 +1032,12 @@ pub fn dropdown_overlay(
}, },
..Default::default() ..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 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)) let backdrop = mouse_area(container(text("")).width(Length::Fill).height(Length::Fill))
@@ -136,26 +136,22 @@ pub fn view<'a>(
Tool::Pen | Tool::Brush | Tool::Eraser => { Tool::Pen | Tool::Brush | Tool::Eraser => {
brush_properties(brush_size, brush_opacity, brush_hardness, colors) brush_properties(brush_size, brush_opacity, brush_hardness, colors)
} }
Tool::Spray => { Tool::Spray => spray_properties(
spray_properties( brush_size,
brush_size, brush_opacity,
brush_opacity, brush_hardness,
brush_hardness, settings_spray_particle_size,
settings_spray_particle_size, settings_spray_density,
settings_spray_density, colors,
colors, ),
)
}
Tool::Select => selection_properties(selection_rect, colors), Tool::Select => selection_properties(selection_rect, colors),
Tool::VectorSelect => { Tool::VectorSelect => vector_select_properties(
vector_select_properties( selected_vector_shape,
selected_vector_shape, vector_shapes,
vector_shapes, vector_angle,
vector_angle, active_layer_id,
active_layer_id, colors,
colors, ),
)
}
Tool::VectorRect Tool::VectorRect
| Tool::VectorCircle | Tool::VectorCircle
| Tool::VectorLine | Tool::VectorLine
@@ -223,7 +219,7 @@ fn layer_info_section<'a>(
width: u32, width: u32,
height: u32, height: u32,
layer_id: u64, layer_id: u64,
_colors: ThemeColors, colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let opacity_slider = plain_slider( let opacity_slider = plain_slider(
"Opacity", "Opacity",
@@ -232,6 +228,7 @@ fn layer_info_section<'a>(
0.01, 0.01,
"%", "%",
0, 0,
colors,
move |v| Message::LayerSetOpacity(layer_id, v), move |v| Message::LayerSetOpacity(layer_id, v),
); );
@@ -342,7 +339,8 @@ fn vector_select_properties<'a>(
0.5, 0.5,
"px", "px",
1, 1,
move |v| Message::VectorStrokeChanged(v), colors,
move |v| { Message::VectorStrokeChanged(v) },
), ),
plain_slider( plain_slider(
"Opacity", "Opacity",
@@ -351,7 +349,8 @@ fn vector_select_properties<'a>(
0.01, 0.01,
"%", "%",
0, 0,
move |v| Message::VectorOpacityChanged(v), colors,
move |v| { Message::VectorOpacityChanged(v) },
), ),
plain_slider( plain_slider(
"Hardness", "Hardness",
@@ -360,7 +359,8 @@ fn vector_select_properties<'a>(
0.01, 0.01,
"%", "%",
0, 0,
move |v| Message::GeometryHardnessChanged(v), colors,
move |v| { Message::GeometryHardnessChanged(v) },
), ),
plain_slider( plain_slider(
"Angle", "Angle",
@@ -369,6 +369,7 @@ fn vector_select_properties<'a>(
1.0, 1.0,
"°", "°",
0, 0,
colors,
move |v| Message::VectorAngleChanged(v), move |v| Message::VectorAngleChanged(v),
), ),
row![ row![
@@ -418,30 +419,18 @@ fn vector_tool_properties<'a>(
points: u32, points: u32,
sides: u32, sides: u32,
vector_draw: Option<((f32, f32), (f32, f32))>, vector_draw: Option<((f32, f32), (f32, f32))>,
_colors: ThemeColors, colors: ThemeColors,
foreground_color: [u8; 4], foreground_color: [u8; 4],
background_color: [u8; 4], background_color: [u8; 4],
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let mut col = column![ let mut col = column![
text("Vector Shape").size(11), text("Vector Shape").size(11),
plain_slider( plain_slider("Stroke", stroke, 0.0..=50.0, 0.5, "px", 1, colors, |v| {
"Stroke", Message::VectorStrokeChanged(v)
stroke, },),
0.0..=50.0, plain_slider("Opacity", opacity, 0.0..=1.0, 0.01, "%", 0, colors, |v| {
0.5, Message::VectorOpacityChanged(v)
"px", },),
1,
|v| Message::VectorStrokeChanged(v),
),
plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::VectorOpacityChanged(v),
),
row![ row![
text("Fill").size(10), text("Fill").size(10),
checkbox("", fill) checkbox("", fill)
@@ -479,6 +468,7 @@ fn vector_tool_properties<'a>(
1.0, 1.0,
"px", "px",
1, 1,
colors,
|v| Message::VectorRadiusChanged(v), |v| Message::VectorRadiusChanged(v),
)); ));
} }
@@ -490,6 +480,7 @@ fn vector_tool_properties<'a>(
1.0, 1.0,
"", "",
0, 0,
colors,
|v| Message::VectorPointsChanged(v as u32), |v| Message::VectorPointsChanged(v as u32),
)); ));
} }
@@ -501,6 +492,7 @@ fn vector_tool_properties<'a>(
1.0, 1.0,
"", "",
0, 0,
colors,
|v| Message::VectorSidesChanged(v as u32), |v| Message::VectorSidesChanged(v as u32),
)); ));
} }
@@ -524,37 +516,19 @@ fn brush_properties<'a>(
size: f32, size: f32,
opacity: f32, opacity: f32,
hardness: f32, hardness: f32,
_colors: ThemeColors, colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
column![ column![
text("Brush Properties").size(11), text("Brush Properties").size(11),
plain_slider( plain_slider("Size", size, 1.0..=200.0, 1.0, "px", 0, colors, |v| {
"Size", Message::BrushSizeChanged(v)
size, },),
1.0..=200.0, plain_slider("Opacity", opacity, 0.0..=1.0, 0.01, "%", 0, colors, |v| {
1.0, Message::BrushOpacityChanged(v)
"px", },),
0, plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| {
|v| Message::BrushSizeChanged(v), Message::BrushHardnessChanged(v)
), },),
plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::BrushOpacityChanged(v),
),
plain_slider(
"Hardness",
hardness,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::BrushHardnessChanged(v),
),
] ]
.spacing(4) .spacing(4)
.into() .into()
@@ -567,19 +541,13 @@ fn spray_properties<'a>(
hardness: f32, hardness: f32,
particle_size: f32, particle_size: f32,
density: u32, density: u32,
_colors: ThemeColors, colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
column![ column![
text("Spray Properties").size(11), text("Spray Properties").size(11),
plain_slider( plain_slider("Size", size, 1.0..=200.0, 1.0, "px", 0, colors, |v| {
"Size", Message::BrushSizeChanged(v)
size, },),
1.0..=200.0,
1.0,
"px",
0,
|v| Message::BrushSizeChanged(v),
),
plain_slider( plain_slider(
"Particle Size", "Particle Size",
particle_size, particle_size,
@@ -587,6 +555,7 @@ fn spray_properties<'a>(
0.1, 0.1,
"px", "px",
1, 1,
colors,
Message::SprayParticleSizeChanged, Message::SprayParticleSizeChanged,
), ),
plain_slider( plain_slider(
@@ -596,26 +565,15 @@ fn spray_properties<'a>(
1.0, 1.0,
"", "",
0, 0,
|v| Message::SprayDensityChanged(v as u32), colors,
), |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),
), ),
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) .spacing(4)
.into() .into()
@@ -4,8 +4,9 @@
use crate::app::Message; use crate::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::Tool; use hcie_engine_api::Tool;
use iced::widget::{container, row, slider, text}; use iced::widget::{container, row, text};
use iced::{Element, Length}; use iced::{Element, Length};
/// Build the status bar element. /// 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 zoom_pct = text(format!("{:.0}%", zoom * 100.0)).size(11);
let canvas_size = text(format!("{}×{}", canvas_w, canvas_h)).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)) let zoom_slider = container(plain_slider(
.step(0.01) "Zoom",
.width(100); zoom,
0.01..=16.0,
0.01,
"×",
2,
colors,
Message::CanvasZoomSet,
))
.width(120);
let bar = row![ let bar = row![
tool_name, tool_name,
text(" | ").size(11), text(" | ").size(11),
coords, coords,
text(" ").size(11).width(Length::Fill), text(" ").size(11).width(Length::Fill),
text("Zoom:").size(11),
zoom_slider, zoom_slider,
zoom_pct, zoom_pct,
text(" | ").size(11), text(" | ").size(11),
@@ -101,6 +101,48 @@ pub fn panel_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style { iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)), background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_low).width(1), 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() ..Default::default()
} }
} }
@@ -247,7 +289,7 @@ pub fn modern_panel_header(colors: ThemeColors, focused: bool) -> iced::widget::
background: Some(iced::Background::Color(if focused { background: Some(iced::Background::Color(if focused {
colors.bg_hover colors.bg_hover
} else { } else {
colors.bg_active colors.elevated()
})), })),
border: iced::Border { border: iced::Border {
color: if focused { color: if focused {
@@ -258,6 +300,11 @@ pub fn modern_panel_header(colors: ThemeColors, focused: bool) -> iced::widget::
width: 1.0, width: 1.0,
radius: 0.0.into(), 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() ..Default::default()
} }
} }
@@ -556,6 +556,11 @@ impl Program<Message> for SvgCanvasProgram {
(canvas::event::Status::Captured, None) (canvas::event::Status::Captured, None)
} }
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) => { 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 { let amount = match delta {
mouse::ScrollDelta::Lines { y, .. } => y, mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y / 100.0, mouse::ScrollDelta::Pixels { y, .. } => y / 100.0,
@@ -606,9 +611,10 @@ impl Program<Message> for SvgCanvasProgram {
pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Message> { pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Message> {
let poly_count = state.editable.polygons.len(); let poly_count = state.editable.polygons.len();
if poly_count == 0 { if poly_count == 0 {
return container(text("No polygons to edit").size(12)) return crate::dialogs::modal(
.padding(24) container(text("No polygons to edit").size(12)).padding(24),
.into(); colors,
);
} }
let selected_poly = state.selected_poly.min(poly_count - 1); let selected_poly = state.selected_poly.min(poly_count - 1);
let selected_node = state let selected_node = state
@@ -775,6 +781,7 @@ pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Mes
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(move |_theme| iced::widget::container::Style { .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), border: iced::Border::default().color(colors.border_high).width(1),
..Default::default() ..Default::default()
}) })
@@ -789,32 +796,49 @@ pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Mes
.width(920) .width(920)
.height(680); .height(680);
let dialog = container(content).style(move |_theme| iced::widget::container::Style { let dialog_surface = container(Space::with_width(920).height(680))
background: Some(iced::Background::Color(colors.bg_panel)), .width(920)
border: iced::Border::default() .height(680)
.rounded(6) .style(move |_theme| iced::widget::container::Style {
.color(colors.border_high) background: Some(iced::Background::Color(colors.bg_panel)),
.width(1), border: iced::Border::default()
shadow: iced::Shadow { .rounded(6)
color: Color::from_rgba(0.0, 0.0, 0.0, 0.45), .color(colors.border_high)
offset: Vector::new(0.0, 5.0), .width(1),
blur_radius: 16.0, shadow: iced::Shadow {
}, color: Color::from_rgba(0.0, 0.0, 0.0, 0.45),
..Default::default() 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) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.center_x(Length::Fill) .center_x(Length::Fill)
.center_y(Length::Fill) .center_y(Length::Fill)
.padding(20) .padding(20);
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(Color::from_rgba( iced::widget::Stack::new()
0.0, 0.0, 0.0, 0.58, .push(backdrop)
))), .push(positioned_dialog)
..Default::default() .width(Length::Fill)
}) .height(Length::Fill)
.into() .into()
} }
@@ -1,7 +1,9 @@
//! Text editor panel — font, size, color, alignment, effects. //! Text editor panel — font, size, color, alignment, effects.
use crate::app::Message; 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}; use iced::{Element, Length};
/// Build the text editor panel. /// Build the text editor panel.
@@ -11,23 +13,31 @@ pub fn view(
font_color: [u8; 4], font_color: [u8; 4],
_alignment: &str, _alignment: &str,
orientation: &str, orientation: &str,
colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let size_slider = slider(8.0..=200.0, font_size, |v| Message::TextSizeChanged(v)) let size_slider = plain_slider(
.step(1.0) "Size",
.width(Length::Fill); font_size,
8.0..=200.0,
1.0,
"px",
0,
colors,
Message::TextSizeChanged,
);
let r = font_color[0]; let r = font_color[0];
let g = font_color[1]; let g = font_color[1];
let b = font_color[2]; let b = font_color[2];
let color_r = slider(0..=255, r, move |v| { let color_r = plain_slider("R", r as f32, 0.0..=255.0, 1.0, "", 0, colors, move |v| {
Message::TextColorChanged([v, g, b, 255]) Message::TextColorChanged([v as u8, g, b, 255])
}); });
let color_g = slider(0..=255, g, move |v| { let color_g = plain_slider("G", g as f32, 0.0..=255.0, 1.0, "", 0, colors, move |v| {
Message::TextColorChanged([r, v, b, 255]) Message::TextColorChanged([r, v as u8, b, 255])
}); });
let color_b = slider(0..=255, b, move |v| { let color_b = plain_slider("B", b as f32, 0.0..=255.0, 1.0, "", 0, colors, move |v| {
Message::TextColorChanged([r, g, v, 255]) Message::TextColorChanged([r, g, v as u8, 255])
}); });
let align_left = button(text("Left").size(10)) let align_left = button(text("Left").size(10))
@@ -52,12 +62,11 @@ pub fn view(
let panel = column![ let panel = column![
text("Text").size(13), text("Text").size(13),
horizontal_rule(1), horizontal_rule(1),
text(format!("Size: {:.0}", font_size)).size(10),
size_slider, size_slider,
text("Color").size(10), text("Color").size(10),
row![text("R").size(10), color_r].spacing(4), color_r,
row![text("G").size(10), color_g].spacing(4), color_g,
row![text("B").size(10), color_b].spacing(4), color_b,
row![align_left, align_center, align_right].spacing(4), row![align_left, align_center, align_right].spacing(4),
text(format!("Orientation: {}", orient)).size(10), text(format!("Orientation: {}", orient)).size(10),
horizontal_rule(1), horizontal_rule(1),
@@ -27,7 +27,7 @@ pub(crate) const MENU_LEFT_INSET: f32 = 6.0;
/// Unified menu/title bar height shared with dropdown placement. /// Unified menu/title bar height shared with dropdown placement.
pub(crate) const MENU_BAR_HEIGHT: f32 = 30.0; 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. /// 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 { pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 {
@@ -12,8 +12,9 @@
use crate::app::{Message, ToolState}; use crate::app::{Message, ToolState};
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::Tool; 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}; use iced::{Element, Length};
/// Build the Photopea-style tool options bar. /// 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 { let options = match tool_state.active_tool {
// ── Brush / Eraser / Pen / Spray ── // ── Brush / Eraser / Pen / Spray ──
Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => { Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => {
let size_label = text(format!("Size: {:.0}", tool_state.brush_size)) let size_slider = container(plain_slider(
.size(10) "Size",
.width(Length::Fixed(60.0));
let size_slider = slider(
1.0..=200.0,
tool_state.brush_size, tool_state.brush_size,
1.0..=200.0,
1.0,
"px",
0,
colors,
Message::BrushSizeChanged, 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(120);
.width(Length::Fixed(75.0)); let opacity_slider = container(plain_slider(
let hardness_slider = slider( "Opacity",
tool_state.brush_opacity,
0.0..=1.0, 0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushOpacityChanged,
))
.width(120);
let hardness_slider = container(plain_slider(
"Hardness",
tool_state.brush_hardness, tool_state.brush_hardness,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushHardnessChanged, Message::BrushHardnessChanged,
) ))
.step(0.01) .width(120);
.width(Length::Fixed(80.0));
row![ row![
size_label,
size_slider, size_slider,
text(" ").size(10), text(" ").size(10),
opacity_label,
opacity_slider, opacity_slider,
text(" ").size(10), text(" ").size(10),
hardness_label,
hardness_slider, hardness_slider,
] ]
.spacing(2) .spacing(2)
@@ -17,9 +17,7 @@ use crate::settings::AppSettings;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider; use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::Tool; use hcie_engine_api::Tool;
use iced::widget::{ use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
button, column, container, horizontal_rule, row, scrollable, text, text_input,
};
use iced::{Element, Length}; use iced::{Element, Length};
/// Build the tool settings panel for the active tool. /// Build the tool settings panel for the active tool.
@@ -458,12 +456,12 @@ fn prop_slider<F>(
min: f32, min: f32,
max: f32, max: f32,
msg_fn: F, msg_fn: F,
_c: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> ) -> Element<'static, Message>
where where
F: Fn(f32) -> Message + 'static, 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> { fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, Message> {
@@ -8,7 +8,8 @@ use crate::app::Message;
use crate::panels::menus::MenuCommand; use crate::panels::menus::MenuCommand;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; 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}; use iced::{Element, Length};
/// Build the compact toolbar: SVG icon buttons + brush size + opacity in one row. /// 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); .align_y(iced::Alignment::Center);
// ── Brush size + opacity (compact, matching egui) ── // ── Brush size + opacity (compact, matching egui) ──
let size_value = compact_value( let size_sl = container(plain_slider(
format!("S {:.0}px", tool_state.brush_size), "Size",
"Brush size",
c,
);
let size_sl = slider(
1.0..=200.0,
tool_state.brush_size, tool_state.brush_size,
1.0..=200.0,
1.0,
"px",
0,
colors,
Message::BrushSizeChanged, Message::BrushSizeChanged,
) ))
.step(1.0) .width(110);
.width(Length::Fixed(64.0)); let opacity_sl = container(plain_slider(
"Opacity",
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,
tool_state.brush_opacity, tool_state.brush_opacity,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushOpacityChanged, Message::BrushOpacityChanged,
) ))
.step(0.01) .width(110);
.width(Length::Fixed(64.0));
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) .spacing(4)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
@@ -157,13 +155,3 @@ fn sep(colors: ThemeColors) -> Element<'static, Message> {
}) })
.into() .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,
)
}
@@ -147,6 +147,11 @@ pub const TOOL_SLOTS: &[ToolSlot] = &[
label: "Retouch", label: "Retouch",
icon: "icons/spot.svg", icon: "icons/spot.svg",
}, },
ToolSlot {
tools: &[Tool::CustomShape(0)],
label: "Custom Shapes",
icon: "icons/star.svg",
},
]; ];
/// Build the Photopea-style sidebar element. /// Build the Photopea-style sidebar element.
@@ -158,17 +163,15 @@ pub fn view<'a>(
fg_color: &'a [u8; 4], fg_color: &'a [u8; 4],
bg_color: &'a [u8; 4], bg_color: &'a [u8; 4],
colors: ThemeColors, colors: ThemeColors,
active_slot: usize, open_subtool_slot: Option<usize>,
sub_tools_open: bool,
slot_last_used: &'a [usize], slot_last_used: &'a [usize],
expanded: bool, expanded: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let sidebar_width = if expanded { 72 } else { 36 }; let sidebar_width = if expanded { 72 } else { 36 };
log::info!( log::info!(
"[sidebar] expanded={} active_slot={} sub_tools_open={}", "[sidebar] expanded={} open_subtool_slot={:?}",
expanded, expanded,
active_slot, open_subtool_slot
sub_tools_open
); );
// Tool buttons — single column or 2-column grid depending on expanded state // Tool buttons — single column or 2-column grid depending on expanded state
@@ -184,12 +187,11 @@ pub fn view<'a>(
tool_state, tool_state,
colors, colors,
idx, idx,
active_slot, open_subtool_slot,
sub_tools_open,
slot_last_used, 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 popup_row {
if row_items > 0 { if row_items > 0 {
tool_list = tool_list.push(row_children); tool_list = tool_list.push(row_children);
@@ -219,8 +221,7 @@ pub fn view<'a>(
tool_state, tool_state,
colors, colors,
idx, idx,
active_slot, open_subtool_slot,
sub_tools_open,
slot_last_used, slot_last_used,
); );
tool_list = tool_list.push(btn); tool_list = tool_list.push(btn);
@@ -434,8 +435,7 @@ fn tool_button<'a>(
tool_state: &ToolState, tool_state: &ToolState,
colors: ThemeColors, colors: ThemeColors,
slot_idx: usize, slot_idx: usize,
active_slot: usize, open_subtool_slot: Option<usize>,
sub_tools_open: bool,
slot_last_used: &[usize], slot_last_used: &[usize],
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let is_active = slot.tools.contains(&tool_state.active_tool); 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 // If slot has sub-tools and is active, show sub-tools popup
let has_sub_tools = slot.tools.len() > 1; 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 primary_tool = represented_tool;
let active = is_active; let active = is_active;
@@ -701,8 +701,8 @@ fn tool_button<'a>(
.padding(1) .padding(1)
.width(68) .width(68)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)), background: Some(iced::Background::Color(colors.bg_active)),
border: iced::Border::default().color(colors.border_high).width(1), border: iced::Border::default().color(colors.accent).width(1),
shadow: iced::Shadow { shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4), color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
offset: iced::Vector::new(1.0, 0.0), offset: iced::Vector::new(1.0, 0.0),
@@ -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 //! **Purpose:** Provides one compact numeric control for every Iced panel, matching the egui
//! Properties panel supports direct value entry from the keyboard. //! `PlainSlider` interaction model without relying on the deprecated Iced `Component` API.
//! The widget is implemented as an iced `Component`, which keeps the //! **Logic & Workflow:** An Iced Canvas retains transient drag/edit state, maps pointer positions
//! transient edit state inside the widget instead of in the application //! to stepped values, captures numeric keyboard input while hovered, and draws a recessed track,
//! state. This trait is deprecated in iced 0.13, but it is still the //! gradient value fill, spinner arrows, centered text, and hover elevation.
//! simplest way to bundle local state with a reusable widget. //! **Side Effects / Dependencies:** Emits caller-provided messages; all state remains local to the
//! widget tree and all colors derive from `ThemeColors`.
#![allow(deprecated)] use crate::theme::ThemeColors;
use iced::keyboard::{self, key::Named, Key};
use iced::widget::{column, component, container, mouse_area, row, slider, text, text_input, Component}; use iced::mouse;
use iced::{Element, Length, Renderer, Theme}; use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke};
use iced::{alignment, border, Element, Length, Pixels, Point, Rectangle, Size};
use std::sync::Arc; use std::sync::Arc;
/// Internal state for a `PlainSlider` instance. const CONTROL_HEIGHT: f32 = 22.0;
#[derive(Debug, Default, Clone)] 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 { pub(crate) struct State {
/// Whether the user is currently typing a value. dragging: bool,
editing: bool, editing: bool,
/// Current contents of the text input while editing.
buffer: String, buffer: String,
} }
/// Internal events produced by the widget UI. /// Canvas program describing one plain slider.
#[derive(Debug, Clone)]
pub(crate) enum Event {
/// The slider thumb was dragged to a new value.
SliderChanged(f32),
/// The value label was clicked; switch to text-input mode.
StartEdit,
/// The text input contents changed.
InputChanged(String),
/// The user pressed Enter or otherwise submitted the typed value.
Submit,
}
/// A numeric slider with an editable value label.
///
/// The value label displays the current value with a suffix such as `%`
/// or `px`. Clicking the label replaces it with a text input so the user
/// can type an exact value.
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct PlainSlider<Message> { struct PlainSlider<Message> {
/// Human-readable label shown to the left of the value.
label: String, label: String,
/// Current value. The parent application owns the authoritative value
/// and passes it back on each frame.
value: f32, value: f32,
/// Inclusive numeric range the value is allowed to occupy.
range: std::ops::RangeInclusive<f32>, range: std::ops::RangeInclusive<f32>,
/// Step size used by the slider thumb.
step: f32, step: f32,
/// Suffix shown after the value (e.g. "%", "px").
suffix: String, suffix: String,
/// Number of decimal places to display.
decimals: usize, decimals: usize,
/// Width of the value label / text input area. colors: ThemeColors,
input_width: f32,
/// Callback invoked whenever the value changes, either by dragging
/// the slider or by submitting the text input.
#[allow(clippy::type_complexity)]
on_change: Arc<dyn Fn(f32) -> Message>, on_change: Arc<dyn Fn(f32) -> Message>,
} }
impl<Message> std::fmt::Debug for PlainSlider<Message> { impl<Message> std::fmt::Debug for PlainSlider<Message> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlainSlider") formatter
.debug_struct("PlainSlider")
.field("label", &self.label) .field("label", &self.label)
.field("value", &self.value) .field("value", &self.value)
.field("range", &self.range) .field("range", &self.range)
.field("step", &self.step) .field("step", &self.step)
.field("suffix", &self.suffix) .field("suffix", &self.suffix)
.field("decimals", &self.decimals) .field("decimals", &self.decimals)
.field("input_width", &self.input_width) .finish_non_exhaustive()
.field("on_change", &"Arc<dyn Fn(f32) -> Message>")
.finish()
} }
} }
impl<Message> PlainSlider<Message> { impl<Message> PlainSlider<Message> {
/// Creates a new editable slider. /// Formats the current value for centered display.
///
/// # 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<String>,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: impl Into<String>,
decimals: usize,
on_change: impl Fn(f32) -> Message + 'static,
) -> Self {
Self {
label: label.into(),
value,
range,
step,
suffix: suffix.into(),
decimals,
input_width: 48.0,
on_change: Arc::new(on_change),
}
}
/// Sets the width of the editable value area.
#[allow(dead_code)]
pub fn input_width(mut self, width: f32) -> Self {
self.input_width = width;
self
}
/// Formats a numeric value for display, including the suffix.
///
/// Percentage values are stored normalized (0..1) and displayed
/// multiplied by 100. Other units are displayed as-is.
fn format_value(&self, value: f32) -> String { fn format_value(&self, value: f32) -> String {
if self.suffix == "%" { let displayed = if self.suffix == "%" {
format!("{:.*}{}", self.decimals, value * 100.0, self.suffix) value * 100.0
} else if self.suffix.is_empty() {
format!("{:.*}", self.decimals, value)
} else { } 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<f32> { fn parse_value(&self, raw: &str) -> Option<f32> {
let trimmed = raw.trim(); let numeric = raw
if trimmed.is_empty() { .trim()
return None;
}
let numeric = trimmed
.trim_end_matches(&self.suffix) .trim_end_matches(&self.suffix)
.trim() .trim()
.replace(',', "."); .replace(',', ".");
let parsed: f32 = numeric.parse().ok()?; let parsed = numeric.parse::<f32>().ok()?;
let value = if self.suffix == "%" { parsed / 100.0 } else { parsed }; let value = if self.suffix == "%" {
Some(value.clamp(*self.range.start(), *self.range.end())) parsed / 100.0
}
}
impl<Message> Component<Message, Theme, Renderer> for PlainSlider<Message>
where
Message: Clone,
{
type State = State;
type Event = Event;
fn update(&mut self, state: &mut State, event: Event) -> Option<Message> {
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()
} else { } else {
let value_label = mouse_area( parsed
text(value_text) };
.size(12) Some(self.snap(value))
.width(Length::Fixed(self.input_width)) }
.align_x(iced::alignment::Horizontal::Right),
)
.on_press(Event::StartEdit);
column![ /// Snaps a value to the configured step relative to the range minimum.
row![ fn snap(&self, value: f32) -> f32 {
text(&self.label).size(12).width(Length::Shrink), let min = *self.range.start();
value_label, let max = *self.range.end();
] if self.step > f32::EPSILON {
.spacing(4) (min + ((value - min) / self.step).round() * self.step).clamp(min, max)
.width(Length::Fill) } else {
.align_y(iced::Alignment::Center), value.clamp(min, max)
slider_control.width(Length::Fill), }
] }
.spacing(2)
.into() /// 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<Message> canvas::Program<Message> for PlainSlider<Message> {
type State = State;
fn draw(
&self,
state: &Self::State,
renderer: &iced::Renderer,
_theme: &iced::Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
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<Message>) {
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::<Message>::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<Message>( pub fn plain_slider<Message>(
label: impl Into<String>, label: impl Into<String>,
value: f32, value: f32,
@@ -256,87 +373,87 @@ pub fn plain_slider<Message>(
step: f32, step: f32,
suffix: impl Into<String>, suffix: impl Into<String>,
decimals: usize, decimals: usize,
colors: ThemeColors,
on_change: impl Fn(f32) -> Message + 'static, on_change: impl Fn(f32) -> Message + 'static,
) -> Element<'static, Message> ) -> Element<'static, Message>
where 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)] #[cfg(test)]
mod tests { mod tests {
use super::PlainSlider; use super::{is_numeric_fragment, PlainSlider, State};
use iced::widget::Component; use crate::theme::{ThemeColors, ThemePreset};
fn dummy(_: f32) -> () {} fn test_slider(suffix: &str) -> PlainSlider<()> {
PlainSlider {
#[test] label: "Opacity".to_string(),
fn formats_percentage_without_decimals() { value: 0.5,
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); range: 0.0..=1.0,
assert_eq!(slider.format_value(0.5), "50%"); step: 0.01,
assert_eq!(slider.format_value(1.0), "100%"); suffix: suffix.to_string(),
assert_eq!(slider.format_value(0.0), "0%"); decimals: 0,
colors: ThemeColors::get(ThemePreset::Photopea),
on_change: std::sync::Arc::new(|_| ()),
}
} }
#[test] #[test]
fn formats_pxx_with_decimals() { fn percentage_format_parse_and_clamp_are_stable() {
let slider = PlainSlider::new("Stroke", 37.0, 0.0..=50.0, 0.5, "px", 1, dummy); let slider = test_slider("%");
assert_eq!(slider.format_value(37.0), "37.0 px"); assert_eq!(slider.format_value(0.5), "Opacity: 50%");
} assert_eq!(slider.parse_value("75"), Some(0.75));
#[test]
fn parses_percentage_input() {
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
assert!((slider.parse_value("75%").unwrap() - 0.75).abs() < 1e-6);
assert!((slider.parse_value("75").unwrap() - 0.75).abs() < 1e-6);
assert_eq!(slider.parse_value(""), None);
}
#[test]
fn parses_px_input() {
let slider = PlainSlider::new("Stroke", 10.0, 0.0..=50.0, 0.5, "px", 1, dummy);
assert!((slider.parse_value("25 px").unwrap() - 25.0).abs() < 1e-6);
assert!((slider.parse_value("25").unwrap() - 25.0).abs() < 1e-6);
}
#[test]
fn clamps_out_of_range_input() {
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
assert_eq!(slider.parse_value("150"), Some(1.0)); assert_eq!(slider.parse_value("150"), Some(1.0));
assert_eq!(slider.parse_value("-10"), Some(0.0));
} }
#[test] #[test]
fn update_emits_on_slider_drag() { fn hover_typing_accepts_only_numeric_fragments() {
let mut slider = PlainSlider::new("Size", 10.0, 1.0..=200.0, 1.0, "px", 0, dummy); let mut state = State::default();
let mut state = super::State::default(); assert!(PlainSlider::<()>::begin_typing(&mut state, "-"));
let message = slider.update(&mut state, super::Event::SliderChanged(42.0)); assert!(state.editing);
assert_eq!(message, Some(())); assert_eq!(state.buffer, "-");
assert!((slider.value - 42.0).abs() < 1e-6); assert!(is_numeric_fragment("12.5"));
assert!(!is_numeric_fragment("px"));
} }
#[test] #[test]
fn update_commits_valid_text_input() { fn pointer_mapping_respects_endpoints_and_step() {
let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy); let slider = test_slider("");
let mut state = super::State::default(); assert_eq!(slider.value_at(0.0, 124.0), 0.0);
slider.update(&mut state, super::Event::StartEdit); assert_eq!(slider.value_at(100.0, 124.0), 1.0);
slider.update(&mut state, super::Event::InputChanged("80".to_string())); assert!((slider.value_at(33.0, 124.0) - 0.33).abs() < 1e-6);
let message = slider.update(&mut state, super::Event::Submit);
assert!((slider.value - 0.8).abs() < 1e-6);
assert_eq!(message, Some(()));
assert!(!state.editing);
}
#[test]
fn update_ignores_invalid_text_input() {
let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
let mut state = super::State::default();
slider.update(&mut state, super::Event::StartEdit);
slider.update(&mut state, super::Event::InputChanged("abc".to_string()));
let message = slider.update(&mut state, super::Event::Submit);
assert!((slider.value - 0.5).abs() < 1e-6);
assert_eq!(message, None);
assert!(!state.editing);
} }
} }
@@ -1,7 +1,7 @@
[package] [package]
name = "iced-panel-adapter" name = "iced-panel-adapter"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[lib] [lib]
crate-type = ["rlib"] crate-type = ["rlib"]
@@ -7,9 +7,11 @@
pub mod theme; pub mod theme;
pub use theme::{ThemeColors, ThemePreset}; 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 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}; use iced::{Element, Length};
/// Message type for panel widget interactions. /// Message type for panel widget interactions.
@@ -20,20 +22,20 @@ pub enum PanelMessage {
} }
/// Renders a complete `ModulePanel` as an Iced element tree. /// 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); let mut col = iced::widget::Column::new().spacing(8);
col = col.push(text(&panel.title).size(16).font(iced::Font::MONOSPACE)); col = col.push(text(&panel.title).size(16).font(iced::Font::MONOSPACE));
for widget in &panel.widgets { for widget in &panel.widgets {
col = col.push(render_widget(widget)); col = col.push(render_widget(widget, colors));
} }
col.into() col.into()
} }
/// Renders a single `WidgetDescription` as an Iced element. /// 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 { match widget {
WidgetDescription::Label { WidgetDescription::Label {
text: label_text, .. text: label_text, ..
@@ -56,17 +58,18 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
let id = id.clone(); let id = id.clone();
let current = *value; let current = *value;
let slider_widget = slider(*min..=*max, current, move |v| { plain_slider::plain_slider(
PanelMessage::ValueChanged(id.clone(), serde_json::Value::from(v)) label.clone(),
}) current,
.step(0.01) *min..=*max,
.width(Length::Fill); 0.01,
"",
iced::widget::Column::new() 2,
.push(text(format!("{}: {:.2}", label, current)).size(12)) colors,
.push(slider_widget) move |new_value| {
.spacing(4) PanelMessage::ValueChanged(id.clone(), serde_json::Value::from(new_value))
.into() },
)
} }
WidgetDescription::ColorPicker { label, rgba, .. } => { WidgetDescription::ColorPicker { label, rgba, .. } => {
let r = rgba[0] as f32 / 255.0; let r = rgba[0] as f32 / 255.0;
@@ -125,7 +128,7 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
.spacing(4) .spacing(4)
.padding(iced::Padding::ZERO.left(12)); .padding(iced::Padding::ZERO.left(12));
for child in children { for child in children {
inner = inner.push(render_widget(child)); inner = inner.push(render_widget(child, colors));
} }
iced::widget::Column::new() iced::widget::Column::new()
@@ -213,4 +213,55 @@ impl ThemeColors {
pub fn current() -> Self { pub fn current() -> Self {
Self::get(ThemePreset::default()) 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,
)
} }
@@ -1,7 +1,7 @@
[package] [package]
name = "iced-panel-ai-chat" name = "iced-panel-ai-chat"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[lib] [lib]
crate-type = ["rlib"] crate-type = ["rlib"]
@@ -1,7 +1,7 @@
[package] [package]
name = "iced-panel-script" name = "iced-panel-script"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
[lib] [lib]
crate-type = ["rlib"] crate-type = ["rlib"]
View File
View File
Executable → Regular
View File
+16 -16
View File
@@ -2,7 +2,7 @@
SATIR SAYISI RAPORU SATIR SAYISI RAPORU
Proje: HCIE-Rust v4 Proje: HCIE-Rust v4
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05 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-brush-engine 4845 satır
hcie-color 85 satır hcie-color 85 satır
hcie-composite 1456 satır hcie-composite 1456 satır
hcie-document 666 satır hcie-document 761 satır
hcie-draw 619 satır hcie-draw 619 satır
hcie-egui-app 37419 satır hcie-egui-app 37437 satır
hcie-engine-api 8360 satır hcie-engine-api 8364 satır
hcie-engine-api-orig 3874 satır hcie-engine-api-orig 3874 satır
hcie-filter 3238 satır hcie-filter 3238 satır
hcie-fx 5535 satır hcie-fx 5535 satır
hcie-history 100 satır hcie-history 139 satır
hcie-iced-app 36049 satır hcie-iced-app 38703 satır
hcie-io 12121 satır hcie-io 12122 satır
hcie-kra 1393 satır hcie-kra 1398 satır
hcie-native 153 satır hcie-native 153 satır
hcie-protocol 3678 satır hcie-protocol 3756 satır
hcie-psd 5120 satır hcie-psd 5120 satır
hcie-psd-saver 1277 satır hcie-psd-saver 1277 satır
hcie-selection 392 satır hcie-selection 392 satır
hcie-text 1010 satır hcie-text 1010 satır
hcie-tile 361 satır hcie-tile 361 satır
hcie-vector 2853 satır hcie-vector 2921 satır
hcie-vision 3131 satır hcie-vision 3131 satır
TOPLAM RUST: 136667 satır TOPLAM RUST: 139629 satır
----------------------------------------- -----------------------------------------
C++ / HEADER (.cpp, .h) C++ / HEADER (.cpp, .h)
@@ -102,14 +102,14 @@ Tarih: 2026-07-19 20:01:21
MARKDOWN (.md) 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) KOD SATIRLARI TOPLAMI (GRAND TOTAL)
========================================= =========================================
Rust: 136667 satır Rust: 139629 satır
C++ / Header: 5031 satır C++ / Header: 5031 satır
JavaScript: 0 satır JavaScript: 0 satır
Svelte: 0 satır Svelte: 0 satır
@@ -119,9 +119,9 @@ Tarih: 2026-07-19 20:01:21
CSS: 0 satır CSS: 0 satır
Shell Script: 1445 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) (JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
========================================= =========================================
+69
View File
@@ -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 <cargo-subcommand> [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 "$@"