b49106dc1d
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.
626 lines
20 KiB
Rust
626 lines
20 KiB
Rust
//! Properties panel — active object/tool properties and layer info.
|
||
//!
|
||
//! Displays layer information at the top (name, opacity, visibility, blend mode,
|
||
//! type, size), followed by tool-specific settings. When VectorSelect is active
|
||
//! and a shape is selected, shows that shape's editable properties.
|
||
|
||
use crate::app::Message;
|
||
use crate::color_picker;
|
||
use crate::color_picker::ColorPickerTarget;
|
||
use crate::panels::styles;
|
||
use crate::panels::typography::{BODY, SECTION};
|
||
use crate::theme::ThemeColors;
|
||
use crate::widgets::plain_slider;
|
||
use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape};
|
||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, text};
|
||
use iced::{Element, Length};
|
||
|
||
/// Human-readable label for a shape at a given index.
|
||
fn shape_label(shapes: &[VectorShape], i: usize) -> String {
|
||
let name = shapes[i].name();
|
||
if !name.is_empty() {
|
||
return name;
|
||
}
|
||
match &shapes[i] {
|
||
VectorShape::Line { .. } => format!("Line #{}", i + 1),
|
||
VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
|
||
VectorShape::Circle { .. } => format!("Circle #{}", i + 1),
|
||
VectorShape::Arrow { .. } => format!("Arrow #{}", i + 1),
|
||
VectorShape::Star { .. } => format!("Star #{}", i + 1),
|
||
VectorShape::Polygon { .. } => format!("Polygon #{}", i + 1),
|
||
VectorShape::Rhombus { .. } => format!("Rhombus #{}", i + 1),
|
||
VectorShape::Cylinder { .. } => format!("Cylinder #{}", i + 1),
|
||
VectorShape::Heart { .. } => format!("Heart #{}", i + 1),
|
||
VectorShape::Bubble { .. } => format!("Bubble #{}", i + 1),
|
||
VectorShape::Gear { .. } => format!("Gear #{}", i + 1),
|
||
VectorShape::Cross { .. } => format!("Cross #{}", i + 1),
|
||
VectorShape::Crescent { .. } => format!("Crescent #{}", i + 1),
|
||
VectorShape::Bolt { .. } => format!("Bolt #{}", i + 1),
|
||
VectorShape::Arrow4 { .. } => format!("4-way Arrow #{}", i + 1),
|
||
VectorShape::SvgShape { kind, .. } => format!("{} #{}", kind, i + 1),
|
||
VectorShape::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1),
|
||
}
|
||
}
|
||
|
||
/// Human-readable label for a layer type.
|
||
fn layer_type_label(lt: LayerType) -> &'static str {
|
||
match lt {
|
||
LayerType::Raster => "Raster",
|
||
LayerType::Vector => "Vector",
|
||
LayerType::Text => "Text",
|
||
LayerType::Mask => "Mask",
|
||
LayerType::Group => "Group",
|
||
}
|
||
}
|
||
|
||
/// Human-readable label for a blend mode.
|
||
fn blend_mode_label(bm: BlendMode) -> &'static str {
|
||
match bm {
|
||
BlendMode::Normal => "Normal",
|
||
BlendMode::Dissolve => "Dissolve",
|
||
BlendMode::Darken => "Darken",
|
||
BlendMode::Multiply => "Multiply",
|
||
BlendMode::ColorBurn => "Color Burn",
|
||
BlendMode::LinearBurn => "Linear Burn",
|
||
BlendMode::DarkerColor => "Darker Color",
|
||
BlendMode::Lighten => "Lighten",
|
||
BlendMode::Screen => "Screen",
|
||
BlendMode::ColorDodge => "Color Dodge",
|
||
BlendMode::LinearDodge => "Linear Dodge",
|
||
BlendMode::LighterColor => "Lighter Color",
|
||
BlendMode::Overlay => "Overlay",
|
||
BlendMode::SoftLight => "Soft Light",
|
||
BlendMode::HardLight => "Hard Light",
|
||
BlendMode::VividLight => "Vivid Light",
|
||
BlendMode::LinearLight => "Linear Light",
|
||
BlendMode::PinLight => "Pin Light",
|
||
BlendMode::HardMix => "Hard Mix",
|
||
BlendMode::Difference => "Difference",
|
||
BlendMode::Exclusion => "Exclusion",
|
||
BlendMode::Subtract => "Subtract",
|
||
BlendMode::Divide => "Divide",
|
||
BlendMode::Hue => "Hue",
|
||
BlendMode::Saturation => "Saturation",
|
||
BlendMode::Color => "Color",
|
||
BlendMode::Luminosity => "Luminosity",
|
||
BlendMode::PassThrough => "Pass Through",
|
||
}
|
||
}
|
||
|
||
/// Build the properties panel.
|
||
pub fn view<'a>(
|
||
active_tool: &Tool,
|
||
brush_size: f32,
|
||
brush_opacity: f32,
|
||
brush_hardness: f32,
|
||
selection_rect: Option<(f32, f32, f32, f32)>,
|
||
vector_draw: Option<((f32, f32), (f32, f32))>,
|
||
colors: ThemeColors,
|
||
layer_name: &'a str,
|
||
layer_opacity: f32,
|
||
layer_visible: bool,
|
||
layer_blend_mode: BlendMode,
|
||
layer_type: LayerType,
|
||
layer_width: u32,
|
||
layer_height: u32,
|
||
selected_vector_shape: Option<usize>,
|
||
vector_shapes: &[VectorShape],
|
||
vector_stroke: f32,
|
||
vector_opacity: f32,
|
||
vector_fill: bool,
|
||
vector_radius: f32,
|
||
vector_points: u32,
|
||
vector_sides: u32,
|
||
vector_angle: f32,
|
||
active_layer_id: u64,
|
||
foreground_color: [u8; 4],
|
||
background_color: [u8; 4],
|
||
settings_spray_particle_size: f32,
|
||
settings_spray_density: u32,
|
||
) -> Element<'a, Message> {
|
||
// Build layer info section at the top
|
||
let layer_section = layer_info_section(
|
||
layer_name,
|
||
layer_opacity,
|
||
layer_visible,
|
||
layer_blend_mode,
|
||
layer_type,
|
||
layer_width,
|
||
layer_height,
|
||
active_layer_id,
|
||
colors,
|
||
);
|
||
|
||
// Build tool/shape-specific section
|
||
let tool_section: Element<'a, Message> = match active_tool {
|
||
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::Select => selection_properties(selection_rect, colors),
|
||
Tool::VectorSelect => vector_select_properties(
|
||
selected_vector_shape,
|
||
vector_shapes,
|
||
vector_angle,
|
||
active_layer_id,
|
||
colors,
|
||
),
|
||
Tool::VectorRect
|
||
| Tool::VectorCircle
|
||
| Tool::VectorLine
|
||
| Tool::VectorArrow
|
||
| Tool::VectorStar
|
||
| Tool::VectorPolygon
|
||
| Tool::VectorRhombus
|
||
| Tool::VectorCylinder
|
||
| Tool::VectorHeart
|
||
| Tool::VectorBubble
|
||
| Tool::VectorGear
|
||
| Tool::VectorCross
|
||
| Tool::VectorCrescent
|
||
| Tool::VectorBolt
|
||
| Tool::VectorArrow4 => vector_tool_properties(
|
||
active_tool,
|
||
vector_stroke,
|
||
vector_opacity,
|
||
vector_fill,
|
||
vector_radius,
|
||
vector_points,
|
||
vector_sides,
|
||
vector_draw,
|
||
colors,
|
||
foreground_color,
|
||
background_color,
|
||
),
|
||
Tool::Text => text_properties(colors),
|
||
Tool::Eyedropper => column![
|
||
text("Eyedropper").size(SECTION),
|
||
text("Click on canvas to pick color").size(BODY),
|
||
]
|
||
.spacing(4)
|
||
.into(),
|
||
Tool::FloodFill => column![
|
||
text("Flood Fill").size(SECTION),
|
||
text("Click on canvas to fill area").size(BODY),
|
||
]
|
||
.spacing(4)
|
||
.into(),
|
||
_ => text(format!("{:?}", active_tool)).size(SECTION).into(),
|
||
};
|
||
|
||
let _ = foreground_color;
|
||
let _ = background_color;
|
||
|
||
let panel = column![layer_section, horizontal_rule(1), tool_section,]
|
||
.spacing(4)
|
||
.padding(4);
|
||
|
||
container(scrollable(panel))
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.style(move |_theme| styles::panel_background(colors))
|
||
.into()
|
||
}
|
||
|
||
/// Layer information section shown at the top of the Properties panel.
|
||
fn layer_info_section<'a>(
|
||
name: &'a str,
|
||
opacity: f32,
|
||
visible: bool,
|
||
blend_mode: BlendMode,
|
||
layer_type: LayerType,
|
||
width: u32,
|
||
height: u32,
|
||
layer_id: u64,
|
||
colors: ThemeColors,
|
||
) -> Element<'a, Message> {
|
||
let opacity_slider = plain_slider(
|
||
"Opacity",
|
||
opacity,
|
||
0.0..=1.0,
|
||
0.01,
|
||
"%",
|
||
0,
|
||
colors,
|
||
move |v| Message::LayerSetOpacity(layer_id, v),
|
||
);
|
||
|
||
column![
|
||
text("Layer Info").size(SECTION),
|
||
row![
|
||
text("Name").size(BODY).width(Length::Fixed(58.0)),
|
||
text(name).size(BODY),
|
||
]
|
||
.spacing(4),
|
||
opacity_slider,
|
||
row![
|
||
text("Visible").size(BODY).width(Length::Fixed(58.0)),
|
||
checkbox("", visible)
|
||
.on_toggle(move |_v| Message::LayerToggleVisibility(layer_id))
|
||
.size(11),
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Alignment::Center),
|
||
row![
|
||
text("Blend").size(BODY).width(Length::Fixed(58.0)),
|
||
text(blend_mode_label(blend_mode)).size(BODY),
|
||
]
|
||
.spacing(4),
|
||
row![
|
||
text("Type").size(BODY).width(Length::Fixed(58.0)),
|
||
text(layer_type_label(layer_type)).size(BODY),
|
||
]
|
||
.spacing(4),
|
||
row![
|
||
text("Size").size(BODY).width(Length::Fixed(58.0)),
|
||
text(format!("{}×{}", width, height)).size(BODY),
|
||
]
|
||
.spacing(4),
|
||
]
|
||
.spacing(4)
|
||
.into()
|
||
}
|
||
|
||
/// VectorSelect shape properties — shown when a shape is selected.
|
||
fn vector_select_properties<'a>(
|
||
selected: Option<usize>,
|
||
shapes: &[VectorShape],
|
||
vector_angle: f32,
|
||
active_layer_id: u64,
|
||
colors: ThemeColors,
|
||
) -> Element<'a, Message> {
|
||
match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) {
|
||
Some((idx, shape)) => {
|
||
let label = shape_label(shapes, idx);
|
||
let stroke = shape.stroke();
|
||
let opacity = shape.shape_opacity();
|
||
let hardness = shape.hardness();
|
||
let filled = shape.is_filled();
|
||
let stroke_color = shape.color();
|
||
let fill_color = shape.fill_color().unwrap_or([0, 0, 0, 255]);
|
||
|
||
let shape_type: String = match shape {
|
||
VectorShape::Line { .. } => "Line".into(),
|
||
VectorShape::Rect { .. } => "Rect".into(),
|
||
VectorShape::Circle { .. } => "Circle".into(),
|
||
VectorShape::Arrow { .. } => "Arrow".into(),
|
||
VectorShape::Star { .. } => "Star".into(),
|
||
VectorShape::Polygon { .. } => "Polygon".into(),
|
||
VectorShape::Rhombus { .. } => "Rhombus".into(),
|
||
VectorShape::Cylinder { .. } => "Cylinder".into(),
|
||
VectorShape::Heart { .. } => "Heart".into(),
|
||
VectorShape::Bubble { .. } => "Bubble".into(),
|
||
VectorShape::Gear { .. } => "Gear".into(),
|
||
VectorShape::Cross { .. } => "Cross".into(),
|
||
VectorShape::Crescent { .. } => "Crescent".into(),
|
||
VectorShape::Bolt { .. } => "Bolt".into(),
|
||
VectorShape::Arrow4 { .. } => "4-way Arrow".into(),
|
||
VectorShape::SvgShape { kind, .. } => kind.clone(),
|
||
VectorShape::FreePath { .. } => "Free Path".into(),
|
||
};
|
||
|
||
// "Edit SVG" button — only for SvgShape variants (EGUI parity)
|
||
let edit_svg_btn: Element<'a, Message> =
|
||
if matches!(shape, VectorShape::SvgShape { .. }) {
|
||
let c = colors;
|
||
button(text("Edit SVG").size(BODY))
|
||
.on_press(Message::SvgEditorOpen {
|
||
layer_id: active_layer_id,
|
||
shape_idx: idx,
|
||
})
|
||
.padding([4, 8])
|
||
.style(move |_theme, _status| iced::widget::button::Style {
|
||
background: Some(iced::Background::Color(c.accent)),
|
||
text_color: iced::Color::WHITE,
|
||
border: iced::Border::default().rounded(3),
|
||
..Default::default()
|
||
})
|
||
.into()
|
||
} else {
|
||
text("").into()
|
||
};
|
||
|
||
column![
|
||
text("Shape Properties").size(SECTION),
|
||
row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
|
||
row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
|
||
edit_svg_btn,
|
||
plain_slider(
|
||
"Stroke",
|
||
stroke,
|
||
0.0..=50.0,
|
||
0.5,
|
||
"px",
|
||
1,
|
||
colors,
|
||
move |v| { Message::VectorStrokeChanged(v) },
|
||
),
|
||
plain_slider(
|
||
"Opacity",
|
||
opacity,
|
||
0.0..=1.0,
|
||
0.01,
|
||
"%",
|
||
0,
|
||
colors,
|
||
move |v| { Message::VectorOpacityChanged(v) },
|
||
),
|
||
plain_slider(
|
||
"Hardness",
|
||
hardness,
|
||
0.0..=1.0,
|
||
0.01,
|
||
"%",
|
||
0,
|
||
colors,
|
||
move |v| { Message::GeometryHardnessChanged(v) },
|
||
),
|
||
plain_slider(
|
||
"Angle",
|
||
vector_angle,
|
||
-180.0..=180.0,
|
||
1.0,
|
||
"°",
|
||
0,
|
||
colors,
|
||
move |v| Message::VectorAngleChanged(v),
|
||
),
|
||
row![
|
||
text("Fill").size(BODY),
|
||
checkbox("", filled)
|
||
.on_toggle(Message::VectorFillToggled)
|
||
.size(11),
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Alignment::Center),
|
||
// Fill color picker (only if fill is enabled)
|
||
if filled {
|
||
color_picker::color_swatch(
|
||
fill_color,
|
||
"Fill Color",
|
||
ColorPickerTarget::GeometryFill,
|
||
)
|
||
} else {
|
||
text("").into()
|
||
},
|
||
// Stroke color picker
|
||
color_picker::color_swatch(
|
||
stroke_color,
|
||
"Stroke Color",
|
||
ColorPickerTarget::GeometryStroke,
|
||
),
|
||
]
|
||
.spacing(4)
|
||
.into()
|
||
}
|
||
None => column![
|
||
text("Vector Select").size(SECTION),
|
||
text("Click a shape on canvas").size(BODY),
|
||
]
|
||
.spacing(4)
|
||
.into(),
|
||
}
|
||
}
|
||
|
||
/// Vector shape tool settings — shown when drawing a new vector shape.
|
||
fn vector_tool_properties<'a>(
|
||
tool: &Tool,
|
||
stroke: f32,
|
||
opacity: f32,
|
||
fill: bool,
|
||
radius: f32,
|
||
points: u32,
|
||
sides: u32,
|
||
vector_draw: Option<((f32, f32), (f32, f32))>,
|
||
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, 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)
|
||
.on_toggle(Message::VectorFillToggled)
|
||
.size(11),
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Alignment::Center),
|
||
// Fill color picker (only if fill is enabled)
|
||
if fill {
|
||
color_picker::color_swatch(
|
||
background_color,
|
||
"Fill Color",
|
||
ColorPickerTarget::Background,
|
||
)
|
||
} else {
|
||
text("").into()
|
||
},
|
||
// Stroke color picker
|
||
color_picker::color_swatch(
|
||
foreground_color,
|
||
"Stroke Color",
|
||
ColorPickerTarget::Foreground,
|
||
),
|
||
]
|
||
.spacing(4);
|
||
|
||
// Shape-specific controls
|
||
match tool {
|
||
Tool::VectorRect => {
|
||
col = col.push(plain_slider(
|
||
"Radius",
|
||
radius,
|
||
0.0..=100.0,
|
||
1.0,
|
||
"px",
|
||
1,
|
||
colors,
|
||
|v| Message::VectorRadiusChanged(v),
|
||
));
|
||
}
|
||
Tool::VectorStar => {
|
||
col = col.push(plain_slider(
|
||
"Points",
|
||
points as f32,
|
||
3.0..=20.0,
|
||
1.0,
|
||
"",
|
||
0,
|
||
colors,
|
||
|v| Message::VectorPointsChanged(v as u32),
|
||
));
|
||
}
|
||
Tool::VectorPolygon => {
|
||
col = col.push(plain_slider(
|
||
"Sides",
|
||
sides as f32,
|
||
3.0..=12.0,
|
||
1.0,
|
||
"",
|
||
0,
|
||
colors,
|
||
|v| Message::VectorSidesChanged(v as u32),
|
||
));
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
// Show size if currently drawing
|
||
if let Some(((x0, y0), (x1, y1))) = vector_draw {
|
||
let w = (x1 - x0).abs() as u32;
|
||
let h = (y1 - y0).abs() as u32;
|
||
col = col.push(horizontal_rule(1));
|
||
col =
|
||
col.push(row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4));
|
||
}
|
||
|
||
col.into()
|
||
}
|
||
|
||
/// Brush tool properties (Pen, Brush, Eraser).
|
||
fn brush_properties<'a>(
|
||
size: f32,
|
||
opacity: f32,
|
||
hardness: f32,
|
||
colors: ThemeColors,
|
||
) -> Element<'a, Message> {
|
||
column![
|
||
text("Brush Properties").size(11),
|
||
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()
|
||
}
|
||
|
||
/// Spray tool properties with particle size and density controls.
|
||
fn spray_properties<'a>(
|
||
size: f32,
|
||
opacity: f32,
|
||
hardness: f32,
|
||
particle_size: f32,
|
||
density: u32,
|
||
colors: ThemeColors,
|
||
) -> Element<'a, Message> {
|
||
column![
|
||
text("Spray Properties").size(11),
|
||
plain_slider("Size", size, 1.0..=200.0, 1.0, "px", 0, colors, |v| {
|
||
Message::BrushSizeChanged(v)
|
||
},),
|
||
plain_slider(
|
||
"Particle Size",
|
||
particle_size,
|
||
1.0..=20.0,
|
||
0.1,
|
||
"px",
|
||
1,
|
||
colors,
|
||
Message::SprayParticleSizeChanged,
|
||
),
|
||
plain_slider(
|
||
"Density",
|
||
density as f32,
|
||
10.0..=1000.0,
|
||
1.0,
|
||
"",
|
||
0,
|
||
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()
|
||
}
|
||
|
||
/// Selection tool properties.
|
||
fn selection_properties<'a>(
|
||
rect: Option<(f32, f32, f32, f32)>,
|
||
_colors: ThemeColors,
|
||
) -> Element<'a, Message> {
|
||
match rect {
|
||
Some((x0, y0, x1, y1)) => {
|
||
let w = (x1 - x0).abs() as u32;
|
||
let h = (y1 - y0).abs() as u32;
|
||
column![
|
||
text("Selection").size(11),
|
||
row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4),
|
||
]
|
||
.spacing(4)
|
||
.into()
|
||
}
|
||
None => column![text("Selection").size(11), text("Drag on canvas").size(10)]
|
||
.spacing(4)
|
||
.into(),
|
||
}
|
||
}
|
||
|
||
/// Text tool properties.
|
||
fn text_properties<'a>(_colors: ThemeColors) -> Element<'a, Message> {
|
||
column![
|
||
text("Text Tool").size(11),
|
||
text("Click on canvas to add text").size(10),
|
||
horizontal_rule(1),
|
||
row![
|
||
button(text("Left").size(10))
|
||
.on_press(Message::TextAlignChanged("Left".to_string()))
|
||
.padding([4, 8]),
|
||
button(text("Center").size(10))
|
||
.on_press(Message::TextAlignChanged("Center".to_string()))
|
||
.padding([4, 8]),
|
||
button(text("Right").size(10))
|
||
.on_press(Message::TextAlignChanged("Right".to_string()))
|
||
.padding([4, 8]),
|
||
]
|
||
.spacing(4),
|
||
]
|
||
.spacing(4)
|
||
.into()
|
||
}
|