diff --git a/crates/op-editor-core/src/command.rs b/crates/op-editor-core/src/command.rs index e629b1400..949571882 100644 --- a/crates/op-editor-core/src/command.rs +++ b/crates/op-editor-core/src/command.rs @@ -40,6 +40,18 @@ pub enum NodeFlag { Collapsed, } +/// Which scalar parameter [`EditorCommand::SetEffectParam`] writes. +/// `OffsetX` / `OffsetY` / `Blur` / `Spread` target a Shadow effect; +/// `Radius` targets a Blur / BackgroundBlur effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectField { + OffsetX, + OffsetY, + Blur, + Spread, + Radius, +} + /// Wire-friendly value payload for [`EditorCommand::SetVariableScalar`] /// — a non-color scalar variable's new value. #[derive(Debug, Clone, PartialEq)] @@ -208,6 +220,13 @@ pub enum EditorCommand { AddNodeEffect { node_id: NodeId, kind: String }, /// Remove the effect at `index` from a node's effect list. RemoveNodeEffect { node_id: NodeId, index: u32 }, + /// Set one scalar parameter of the effect at `index` on a node. + SetEffectParam { + node_id: NodeId, + index: u32, + field: EffectField, + value: f32, + }, /// Set the active canvas tool. SetActiveTool { tool: String }, /// Undo the last change. diff --git a/crates/op-editor-core/src/command_apply.rs b/crates/op-editor-core/src/command_apply.rs index db42c13da..fbd8a6e98 100644 --- a/crates/op-editor-core/src/command_apply.rs +++ b/crates/op-editor-core/src/command_apply.rs @@ -180,6 +180,12 @@ impl EditorState { EditorCommand::RemoveNodeEffect { node_id, index } => { self.cmd_remove_node_effect(&node_id, index) } + EditorCommand::SetEffectParam { + node_id, + index, + field, + value, + } => self.cmd_set_effect_param(&node_id, index, field, value), // --- Variables + themes -------------------------------- EditorCommand::SetVariableColor { name, hex } => self.set_variable_color(&name, &hex), diff --git a/crates/op-editor-core/src/command_attr_tests.rs b/crates/op-editor-core/src/command_attr_tests.rs index ce2b378e5..c3a9a0d22 100644 --- a/crates/op-editor-core/src/command_attr_tests.rs +++ b/crates/op-editor-core/src/command_attr_tests.rs @@ -167,3 +167,63 @@ fn remove_node_effect_rejects_out_of_range() { index: 0, })); } + +#[test] +fn set_effect_param_writes_shadow_offset() { + let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]); + assert!(s.apply(EditorCommand::AddNodeEffect { + node_id: id("n1"), + kind: "shadow".into(), + })); + assert!(s.apply(EditorCommand::SetEffectParam { + node_id: id("n1"), + index: 0, + field: crate::EffectField::OffsetX, + value: 12.0, + })); + match find_node(s.active_children(), &id("n1")).unwrap() { + PenNode::Rectangle(r) => match &r.container.effects.as_ref().unwrap()[0] { + jian_ops_schema::style::PenEffect::Shadow(sh) => assert_eq!(sh.offset_x, 12.0), + other => panic!("expected shadow, got {other:?}"), + }, + other => panic!("expected rect, got {other:?}"), + } +} + +#[test] +fn set_effect_param_clamps_blur_radius_to_non_negative() { + let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]); + assert!(s.apply(EditorCommand::AddNodeEffect { + node_id: id("n1"), + kind: "blur".into(), + })); + assert!(s.apply(EditorCommand::SetEffectParam { + node_id: id("n1"), + index: 0, + field: crate::EffectField::Radius, + value: -9.0, + })); + match find_node(s.active_children(), &id("n1")).unwrap() { + PenNode::Rectangle(r) => match &r.container.effects.as_ref().unwrap()[0] { + jian_ops_schema::style::PenEffect::Blur(b) => assert_eq!(b.radius, 0.0), + other => panic!("expected blur, got {other:?}"), + }, + other => panic!("expected rect, got {other:?}"), + } +} + +#[test] +fn set_effect_param_rejects_field_effect_mismatch() { + let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]); + assert!(s.apply(EditorCommand::AddNodeEffect { + node_id: id("n1"), + kind: "blur".into(), + })); + // OffsetX is a Shadow-only field — rejected on a Blur effect. + assert!(!s.apply(EditorCommand::SetEffectParam { + node_id: id("n1"), + index: 0, + field: crate::EffectField::OffsetX, + value: 5.0, + })); +} diff --git a/crates/op-editor-core/src/command_node_attrs.rs b/crates/op-editor-core/src/command_node_attrs.rs index 654bb88d8..80855343b 100644 --- a/crates/op-editor-core/src/command_node_attrs.rs +++ b/crates/op-editor-core/src/command_node_attrs.rs @@ -12,7 +12,7 @@ //! Each helper keeps the validate-then-mutate discipline: kind / range //! / hex checks happen BEFORE the mutable borrow + write. -use crate::command::NodeFlag; +use crate::command::{EffectField, NodeFlag}; use crate::fills::{set_primary_fill_hex, set_primary_stroke_hex}; use crate::node_id::NodeId; use crate::pen_node_ext::PenNodeExt; @@ -408,4 +408,42 @@ impl EditorState { } true } + + /// `SetEffectParam` — write one scalar param of the effect at + /// `index`. Blur values are clamped to ≥ 0. Rejects a non-finite + /// value, an out-of-range index, or a field that doesn't match + /// the effect variant (e.g. `Radius` on a Shadow). + pub(crate) fn cmd_set_effect_param( + &mut self, + node_id: &NodeId, + index: u32, + field: EffectField, + value: f32, + ) -> bool { + if !node_id.is_real() || !value.is_finite() { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), node_id) else { + return false; + }; + let Some(slot) = node_effects_slot(node) else { + return false; + }; + let Some(effects) = slot.as_mut() else { + return false; + }; + let Some(effect) = effects.get_mut(index as usize) else { + return false; + }; + match (effect, field) { + (PenEffect::Shadow(s), EffectField::OffsetX) => s.offset_x = value, + (PenEffect::Shadow(s), EffectField::OffsetY) => s.offset_y = value, + (PenEffect::Shadow(s), EffectField::Blur) => s.blur = value.max(0.0), + (PenEffect::Shadow(s), EffectField::Spread) => s.spread = value, + (PenEffect::Blur(b), EffectField::Radius) + | (PenEffect::BackgroundBlur(b), EffectField::Radius) => b.radius = value.max(0.0), + _ => return false, + } + true + } } diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index e000aed76..7386f89b1 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -62,7 +62,7 @@ pub use agent_settings::{ pub use align::AlignAction; pub use chat::{AgentProvider, ChatAnchor, ChatMessage, ChatRole, ChatState, ModelEntry}; pub use color_picker::{hsv_to_rgb, parse_hex_rgb, rgb_to_hex, rgb_to_hsv}; -pub use command::{BatchInsertItem, EditorCommand, NodeFlag, VariableScalarPayload}; +pub use command::{BatchInsertItem, EditorCommand, EffectField, NodeFlag, VariableScalarPayload}; pub use components::{Component, ComponentLibrary}; pub use editor_ui_state::{ BooleanOp, EditorUiState, ExportFormat, FileAction, FileMenuChoice, FillType, FlexLayout, diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index a42c83777..d4c063582 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -68,7 +68,9 @@ pub const PROPERTY_PANEL_WIDTH: f32 = 280.0; /// Button / checkbox actions in the property panel that don't /// map to a text input. The host dispatches these in `apply_press` /// after the text-input hit-test misses. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// `PartialEq` only (not `Eq`) — `AdjustEffectParam` carries an `f32`. +#[derive(Debug, Clone, Copy, PartialEq)] pub enum PropertyPanelAction { SetFlexLayout(op_editor_core::FlexLayout), ToggleSizeFillWidth, @@ -93,6 +95,15 @@ pub enum PropertyPanelAction { /// User clicked the "✕" on an effect row — host removes the /// effect at this index from the selected node. RemoveEffect(usize), + /// User clicked a "−" / "+" stepper on an effect parameter row. + /// `new_value` is the post-step value (the walker computed it + /// from the current value ± the step); the host writes it via + /// `EditorCommand::SetEffectParam`. + AdjustEffectParam { + effect: usize, + field: op_editor_core::EffectField, + new_value: f32, + }, } /// Per-NodeKind toggles for which property-panel sections render. @@ -243,6 +254,19 @@ pub struct EffectSummary { } impl EffectSummary { + /// Current value of one editable parameter — Blur / BackgroundBlur + /// keep their radius in `blur`, so `Blur` and `Radius` both read + /// that field. + pub fn param_value(&self, field: op_editor_core::EffectField) -> f32 { + use op_editor_core::EffectField as F; + match field { + F::OffsetX => self.offset_x, + F::OffsetY => self.offset_y, + F::Blur | F::Radius => self.blur, + F::Spread => self.spread, + } + } + /// Summarise a canonical `PenEffect` for the panel. fn from_pen_effect(e: &jian_ops_schema::style::PenEffect) -> Self { use jian_ops_schema::style::PenEffect; @@ -532,13 +556,13 @@ impl PropertyPanel { fill: caps.fill, stroke: caps.stroke, effects: caps.effects, - effect_count: self.snapshot.effects.len(), export: caps.export, fill_type: self.fill_type, }; let rects = sections::action_button_rects_with_fill_picker( panel_rect, visible, + &self.snapshot.effects, self.fill_type_picker_open, ); // Picker rows live in `rects` AFTER the dropdown rect, so @@ -570,7 +594,6 @@ impl PropertyPanel { fill: caps.fill, stroke: caps.stroke, effects: caps.effects, - effect_count: self.snapshot.effects.len(), export: caps.export, fill_type: self.fill_type, }; @@ -733,7 +756,6 @@ impl Widget for PropertyPanel { fill: caps.fill, stroke: caps.stroke, effects: caps.effects, - effect_count: self.snapshot.effects.len(), export: caps.export, fill_type: self.fill_type, }; diff --git a/crates/op-editor-ui/src/widgets/property_panel_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_layout.rs index b15bb4b43..118eef904 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layout.rs @@ -6,12 +6,12 @@ //! Pulled out of `property_panel_sections.rs` to keep that file //! under the 800-line ceiling. -use crate::widgets::property_panel::PropertyPanelAction; +use crate::widgets::property_panel::{EffectKind, EffectSummary, PropertyPanelAction}; use crate::widgets::property_panel_inputs::{ HEADER_HEIGHT, INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, TAB_HEIGHT, }; use crate::{Point2D, Rect}; -use op_editor_core::{FillType, FlexLayout, PropertyFocus}; +use op_editor_core::{EffectField, FillType, FlexLayout, PropertyFocus}; /// Whether each section currently paints — drives the layout /// walk so when per-kind filtering hides a section, the rects @@ -26,15 +26,12 @@ pub struct VisibleSections { pub fill: bool, /// `StrokeHex` + `StrokeWidth` from the Stroke section. pub stroke: bool, - /// Effects section paints (header + add chip + one row per + /// Effects section paints (header + add chip + one block per /// effect). Tracked because the export-rect walker needs to know /// whether it consumed vertical space ahead of the Export - /// section. + /// section. The per-effect geometry is driven by the `effects` + /// slice the walker takes alongside this struct. pub effects: bool, - /// Number of effect rows the Effects section paints — drives the - /// section's variable height + the per-row "✕" hit rects. 0 when - /// the section is hidden or the node has no effects. - pub effect_count: usize, /// Export section paints — `OpenExportDialog` action emits /// only when this is true. pub export: bool, @@ -53,27 +50,52 @@ impl VisibleSections { fill: true, stroke: true, effects: true, - effect_count: 0, export: true, fill_type: FillType::Solid, }; } -/// Height (px) of one effect row in the Effects section — the type -/// label + the "✕" remove button. +/// Height (px) of an effect's header row — the type label + "✕". pub const EFFECT_ROW_HEIGHT: f32 = INPUT_HEIGHT + 4.0; -/// Total vertical space the Effects section consumes for -/// `effect_count` effects: the section header plus one row per -/// effect, or an 8 px filler when the node has none. Paint -/// (`paint_effects_section`) and the layout walker both consult this -/// so their y-math can never drift. -pub fn effects_section_height(effect_count: usize) -> f32 { +/// Height (px) of one effect-parameter row — label + value + the +/// "−" / "+" steppers. +pub const EFFECT_PARAM_ROW_HEIGHT: f32 = INPUT_HEIGHT + 2.0; + +/// Doc-px a single "−" / "+" stepper click moves an effect parameter. +pub const EFFECT_PARAM_STEP: f32 = 1.0; + +/// The editable scalar params an effect kind exposes, in row order, +/// each paired with its short row label. Shadow exposes four; the +/// blur kinds expose a single radius. +pub fn effect_param_fields(kind: EffectKind) -> &'static [(EffectField, &'static str)] { + match kind { + EffectKind::Shadow => &[ + (EffectField::OffsetX, "X"), + (EffectField::OffsetY, "Y"), + (EffectField::Blur, "Blur"), + (EffectField::Spread, "Spread"), + ], + EffectKind::Blur | EffectKind::BackgroundBlur => &[(EffectField::Radius, "Radius")], + } +} + +/// Total height one effect block consumes — its header row plus one +/// row per editable parameter. +pub fn effect_block_height(kind: EffectKind) -> f32 { + EFFECT_ROW_HEIGHT + effect_param_fields(kind).len() as f32 * EFFECT_PARAM_ROW_HEIGHT +} + +/// Total vertical space the Effects section consumes: the section +/// header plus one block per effect (or an 8 px filler when the node +/// has none). Paint (`paint_effects_section`) and the action-rect +/// walker both consult this so their y-math can never drift. +pub fn effects_section_height(effects: &[EffectSummary]) -> f32 { SECTION_HEADER_HEIGHT - + if effect_count == 0 { + + if effects.is_empty() { 8.0 } else { - effect_count as f32 * EFFECT_ROW_HEIGHT + effects.iter().map(|e| effect_block_height(e.kind)).sum() } } @@ -109,16 +131,19 @@ pub fn fill_body_height(fill_type: FillType) -> f32 { pub fn action_button_rects( panel_rect: Rect, visible: VisibleSections, + effects: &[EffectSummary], ) -> Vec<(PropertyPanelAction, Rect)> { - action_button_rects_with_fill_picker(panel_rect, visible, false) + action_button_rects_with_fill_picker(panel_rect, visible, effects, false) } /// Same as `action_button_rects` but `fill_picker_open == true` /// emits hit-rects for the 4 picker rows that overlay the Fill -/// section. +/// section. `effects` drives the Effects section's per-effect "✕" +/// and parameter-stepper rects + that section's variable height. pub fn action_button_rects_with_fill_picker( panel_rect: Rect, visible: VisibleSections, + effects: &[EffectSummary], fill_picker_open: bool, ) -> Vec<(PropertyPanelAction, Rect)> { let x0 = panel_rect.origin.x; @@ -263,30 +288,57 @@ pub fn action_button_rects_with_fill_picker( y += SECTION_GAP; } if visible.effects { - // Mirrors `paint_effects_section`: header + one row per - // effect. The header's "+" button (drawn by - // `paint_section_label_with_add` at the right edge) maps to - // an `AddEffect` action. + // Mirrors `paint_effects_section`: header + one block per + // effect (header row + parameter rows). The header's "+" + // button maps to `AddEffect`. let plus = Rect { origin: Point2D::new(x0 + w - PAD_X - 22.0, y), size: Point2D::new(28.0, SECTION_HEADER_HEIGHT), }; out.push((PropertyPanelAction::AddEffect, plus)); y += SECTION_HEADER_HEIGHT; - // One right-aligned "✕" per effect row → RemoveEffect(i). - for i in 0..visible.effect_count { - let row_y = y + i as f32 * EFFECT_ROW_HEIGHT; + for (ei, eff) in effects.iter().enumerate() { + // "✕" on the effect's header row → RemoveEffect. out.push(( - PropertyPanelAction::RemoveEffect(i), + PropertyPanelAction::RemoveEffect(ei), Rect { - origin: Point2D::new(x0 + w - PAD_X - 20.0, row_y + 2.0), + origin: Point2D::new(x0 + w - PAD_X - 20.0, y + 2.0), size: Point2D::new(20.0, INPUT_HEIGHT), }, )); + // One "−"/"+" stepper pair per editable parameter row. + let mut py = y + EFFECT_ROW_HEIGHT; + for &(field, _) in effect_param_fields(eff.kind) { + let cur = eff.param_value(field); + out.push(( + PropertyPanelAction::AdjustEffectParam { + effect: ei, + field, + new_value: cur - EFFECT_PARAM_STEP, + }, + Rect { + origin: Point2D::new(x0 + w - PAD_X - 48.0, py + 3.0), + size: Point2D::new(22.0, INPUT_HEIGHT - 6.0), + }, + )); + out.push(( + PropertyPanelAction::AdjustEffectParam { + effect: ei, + field, + new_value: cur + EFFECT_PARAM_STEP, + }, + Rect { + origin: Point2D::new(x0 + w - PAD_X - 22.0, py + 3.0), + size: Point2D::new(22.0, INPUT_HEIGHT - 6.0), + }, + )); + py += EFFECT_PARAM_ROW_HEIGHT; + } + y += effect_block_height(eff.kind); + } + if effects.is_empty() { + y += 8.0; } - // Advance past the rows — identical to effects_section_height - // minus the header already consumed above. - y += effects_section_height(visible.effect_count) - SECTION_HEADER_HEIGHT; y += SECTION_GAP; } if visible.export { diff --git a/crates/op-editor-ui/src/widgets/property_panel_sections.rs b/crates/op-editor-ui/src/widgets/property_panel_sections.rs index a69a8335d..29d48ef4b 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_sections.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_sections.rs @@ -12,7 +12,9 @@ use crate::widgets::property_panel_inputs::{ paint_section_label, paint_section_label_with_add, to_jian_color, HEADER_HEIGHT, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, TAB_HEIGHT, }; -use crate::widgets::property_panel_layout::EFFECT_ROW_HEIGHT; +use crate::widgets::property_panel_layout::{ + effect_param_fields, EFFECT_PARAM_ROW_HEIGHT, EFFECT_ROW_HEIGHT, +}; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::PropertyFocus; @@ -710,12 +712,67 @@ pub fn paint_effects_section( for eff in effects { paint_effect_row(cx, theme, eff, x, row_y, width); row_y += EFFECT_ROW_HEIGHT; + for &(field, label) in effect_param_fields(eff.kind) { + paint_effect_param_row(cx, theme, label, eff.param_value(field), x, row_y, width); + row_y += EFFECT_PARAM_ROW_HEIGHT; + } } } paint_section_divider(cx, theme, x, row_y, width); row_y + SECTION_GAP } +/// Paint one effect-parameter row: `