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