feat(panels): shadow/blur parameter steppers in the Effects section

Completes the Effects-controls gap — each effect row now exposes its
editable scalar parameters:

- op-editor-core: `EffectField` enum + `EditorCommand::SetEffectParam`
  + `cmd_set_effect_param` — writes one shadow param (offset X/Y,
  blur, spread) or a blur/background-blur radius; blur values clamp
  to >= 0, field/effect mismatches reject.
- panels: each effect block paints a parameter row per field —
  `<label> <value> [-] [+]`; the "-"/"+" steppers emit
  `AdjustEffectParam` (the walker computes the post-step value from
  the current one). `effects_section_height` / the action-rect
  walker now take the effects slice so the variable per-kind block
  height stays aligned with paint (`VisibleSections.effect_count`
  retired in favour of the slice).
- both hosts dispatch `AdjustEffectParam` via `SetEffectParam`,
  history-committed.

op-editor-core 233 / op-editor-ui 140 / op-host-desktop 64 tests
green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 14:38:26 +08:00
parent e30f3b12b4
commit 722445b017
11 changed files with 336 additions and 42 deletions

View file

@ -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.

View file

@ -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),

View file

@ -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,
}));
}

View file

@ -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
}
}

View file

@ -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,

View file

@ -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,
};

View file

@ -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 {

View file

@ -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: `<label> <value> [−] [+]`. The
/// "−"/"+" stepper rects must match `action_button_rects`'s
/// `AdjustEffectParam` rects exactly so paint + hit-test agree.
fn paint_effect_param_row(
cx: &mut PaintCx<'_>,
theme: &Theme,
label: &str,
value: f32,
x: f32,
y: f32,
width: f32,
) {
let label_layout = TextLayout::single_run(
label,
"system-ui",
11.0,
to_jian_color(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&label_layout, Point2D::new(x + PAD_X + 4.0, y + 15.0));
let value_text = format!("{value:.0}");
let value_layout = TextLayout::single_run(
&value_text,
"system-ui",
12.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&value_layout,
Point2D::new(x + width - PAD_X - 78.0, y + 15.0),
);
// "−" then "+" — geometry mirrors the `AdjustEffectParam` rects.
for (icon, off) in [(Icon::Minus, 48.0_f32), (Icon::Plus, 22.0_f32)] {
let r = Rect {
origin: Point2D::new(x + width - PAD_X - off, y + 3.0),
size: Point2D::new(22.0, INPUT_HEIGHT - 6.0),
};
cx.backend.fill_round_rect(r, 6.0, theme.muted);
draw_icon(
cx.backend,
icon,
Point2D::new(r.origin.x + 5.0, r.origin.y + (r.size.y - 12.0) / 2.0),
12.0,
theme.foreground,
1.4,
);
}
}
/// Paint one effect row — the effect-type label on the left + a
/// right-aligned "✕" remove glyph. The "✕" hit rect is emitted by
/// `action_button_rects` as `RemoveEffect(index)`, so the glyph

View file

@ -85,11 +85,15 @@ fn hit_test_action_export_section_returns_open_dialog() {
fill: caps.fill,
stroke: caps.stroke,
effects: caps.effects,
effect_count: panel.snapshot.effects.len(),
export: caps.export,
fill_type: panel.fill_type,
};
let rects = sections::action_button_rects_with_fill_picker(rect, visible, false);
let rects = sections::action_button_rects_with_fill_picker(
rect,
visible,
&panel.snapshot.effects,
false,
);
let export_rect = rects
.iter()
.find(|(action, _)| matches!(action, PropertyPanelAction::OpenExportDialog))

View file

@ -72,6 +72,24 @@ impl WidgetHostNative {
});
}
}
A::AdjustEffectParam {
effect,
field,
new_value,
} => {
let id = self.editor_state.selection.anchor.clone();
if id.is_real() {
self.editor_state.commit_history();
let _ =
self.editor_state
.apply(op_editor_core::EditorCommand::SetEffectParam {
node_id: id,
index: effect as u32,
field,
value: new_value,
});
}
}
}
self.mark_dirty();
}

View file

@ -79,6 +79,24 @@ impl WidgetHost {
});
}
}
A::AdjustEffectParam {
effect,
field,
new_value,
} => {
let id = self.editor_state.selection.anchor.clone();
if id.is_real() {
self.editor_state.commit_history();
let _ =
self.editor_state
.apply(op_editor_core::EditorCommand::SetEffectParam {
node_id: id,
index: effect as u32,
field,
value: new_value,
});
}
}
}
self.mark_dirty();
}