openpencil/crates/op-editor-core/src/command_apply.rs
Kayshen-X e8cf002a7f feat(mcp): expose UIKit components as insert_<comp> MCP tools
Closes the architectural piece of the "MCP element toolset" P1 gap
(TS pen-mcp ships ~100 add_card_*/add_toast_* element tools).

- op-editor-core: new `EditorCommand::InstantiateKitComponent`
  variant + applier branch that calls
  `EditorState::instantiate_kit_component` with the requested
  drop point (defaults to (0, 0)).
- op-mcp: `element_tools.rs` — `InsertKitComponent` per-component
  tool returning `OkWithCommand(_, InstantiateKitComponent)`;
  `insert_kit_component_tools(state)` walks every loaded kit;
  `element_tool_schemas(state)` emits the matching tools/list
  JSON. Tool names sanitize dashes: `insert_btn_primary`,
  `insert_card_basic`, etc.
- op-host-desktop: `rebuild_registry` chains the dynamic tools
  in; `tools_list_response` takes EditorState and appends the
  dynamic schemas next to TOOL_SCHEMAS.
- op-editor-core: tidies the empty-pages `ensure_pages` guard to
  `is_none_or`.

Result: 6 starter-kit components → 6 working MCP tools. The 100-tool
catalog parity is now a data fill-in (more components in op-editor-
core/uikit.rs auto-register as more MCP tools).
2026-05-21 21:55:44 +08:00

438 lines
17 KiB
Rust

//! [`EditorState::apply`] — apply one [`EditorCommand`] against the
//! editor state.
//!
//! Ported from `openpencil-shell-core::document::apply_mcp_command`.
//! Preserves the two shell-core invariants:
//!
//! - **Pre-validate-then-mutate.** Every argument (id space, target
//! existence, geometry, hex, container-children consent) is checked
//! BEFORE any tree write, so a bad arg never half-mutates the
//! document. The raw-node helpers in [`crate::command_node`] and
//! the attribute helpers in [`crate::command_node_attrs`] keep that
//! discipline internally.
//! - **`ReplaceNode` destructive-swap guard.** Replacing a node WITH
//! children requires `drop_children == true`.
//!
//! The result type is `bool` — identical to shell-core's
//! `apply_mcp_command`: `true` when the command changed something (so
//! a host can decide whether to push undo / persist), `false` on an
//! apply-time validation failure.
//!
//! ## Component-command gap
//!
//! `op-editor-core` has no component registry — that was a shell-core
//! `Document` concern and the canonical-schema component model is a
//! later task. The four `*Component` variants are part of the DTO for
//! surface fidelity, but `apply` rejects them (`false`).
use crate::align::AlignAction;
use crate::command::{EditorCommand, VariableScalarPayload};
use crate::node_id::NodeId;
use crate::state::EditorState;
use crate::tool::Tool;
use crate::viewport::Viewport;
use crate::walkers::find_node;
use jian_ops_schema::variable::{VariableKind, VariableScalar};
/// Resolve an `align` action string into an [`AlignAction`].
fn parse_align_action(s: &str) -> Option<AlignAction> {
match s {
"left" => Some(AlignAction::Left),
"center_h" => Some(AlignAction::CenterH),
"right" => Some(AlignAction::Right),
"top" => Some(AlignAction::Top),
"center_v" => Some(AlignAction::CenterV),
"bottom" => Some(AlignAction::Bottom),
"distribute_h" => Some(AlignAction::DistributeH),
"distribute_v" => Some(AlignAction::DistributeV),
_ => None,
}
}
/// Resolve a `tool` string into a [`Tool`].
fn parse_tool(s: &str) -> Option<Tool> {
match s {
"select" => Some(Tool::Select),
"rect" => Some(Tool::Rect),
"ellipse" => Some(Tool::Ellipse),
"polygon" => Some(Tool::Polygon),
"line" => Some(Tool::Line),
"pen" => Some(Tool::Pen),
"text" => Some(Tool::Text),
"frame" => Some(Tool::Frame),
"hand" => Some(Tool::Hand),
_ => None,
}
}
/// Resolve a variable `kind` string into a [`VariableKind`].
fn parse_variable_kind(s: &str) -> Option<VariableKind> {
match s {
"color" => Some(VariableKind::Color),
"number" => Some(VariableKind::Number),
"boolean" => Some(VariableKind::Boolean),
"string" => Some(VariableKind::String),
_ => None,
}
}
impl EditorState {
/// Apply one [`EditorCommand`]. Returns `true` when the command
/// actually changed the document / editor state, `false` on an
/// apply-time validation failure.
pub fn apply(&mut self, cmd: EditorCommand) -> bool {
match cmd {
// --- Raw node CRUD -------------------------------------
EditorCommand::InsertNode {
kind,
name,
x,
y,
width,
height,
fill_hex,
} => self.cmd_insert_node(&kind, &name, x, y, width, height, &fill_hex),
EditorCommand::UpdateNode {
node_id,
x,
y,
width,
height,
name,
fill_hex,
} => self.cmd_update_node(&node_id, x, y, width, height, &name, &fill_hex),
EditorCommand::DeleteNode { node_id } => self.cmd_delete_node(&node_id),
EditorCommand::MoveNode {
node_id,
target_parent,
} => self.cmd_move_node(&node_id, &target_parent),
EditorCommand::CopyNode {
node_id,
target_parent,
} => self.cmd_copy_node(&node_id, &target_parent),
EditorCommand::ReplaceNode {
node_id,
kind,
name,
x,
y,
width,
height,
fill_hex,
drop_children,
} => self.cmd_replace_node(
&node_id,
&kind,
&name,
x,
y,
width,
height,
&fill_hex,
drop_children,
),
EditorCommand::BatchInsert { items } => self.cmd_batch_insert(&items),
// --- Per-node attribute writers ------------------------
EditorCommand::SetNodeRotation { node_id, degrees } => {
self.cmd_set_node_rotation(&node_id, degrees)
}
EditorCommand::SetNodeText { node_id, text } => self.cmd_set_node_text(&node_id, &text),
EditorCommand::SetNodeCornerRadius { node_id, radius } => {
self.cmd_set_node_corner_radius(&node_id, radius)
}
EditorCommand::SetNodeFontSize { node_id, font_size } => {
self.cmd_set_node_font_size(&node_id, font_size)
}
EditorCommand::SetNodeFontWeight {
node_id,
font_weight,
} => self.cmd_set_node_font_weight(&node_id, font_weight),
EditorCommand::SetNodeStrokeHex { node_id, hex } => {
self.cmd_set_node_stroke_hex(&node_id, &hex)
}
EditorCommand::SetNodeStrokeWidth { node_id, width } => {
self.cmd_set_node_stroke_width(&node_id, width)
}
EditorCommand::SetNodeFillHex { node_id, hex } => {
self.cmd_set_node_fill_hex(&node_id, &hex)
}
EditorCommand::SetNodeName { node_id, name } => self.cmd_set_node_name(&node_id, &name),
EditorCommand::SetNodeFlag {
node_id,
flag,
value,
} => self.cmd_set_node_flag(&node_id, flag, value),
EditorCommand::SetNodeFlip {
node_id,
flip_x,
flip_y,
} => self.cmd_set_node_flip(&node_id, flip_x, flip_y),
EditorCommand::SetEllipseArc {
node_id,
start_angle,
sweep_angle,
inner_radius,
} => self.cmd_set_ellipse_arc(&node_id, start_angle, sweep_angle, inner_radius),
EditorCommand::AddNodeEffect { node_id, kind } => {
self.cmd_add_node_effect(&node_id, &kind)
}
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),
EditorCommand::SetVariableScalar { name, scalar } => match scalar {
VariableScalarPayload::Number(n) => self.set_variable_number(&name, n),
VariableScalarPayload::String(s) => self.set_variable_string(&name, s),
VariableScalarPayload::Boolean(b) => self.set_variable_boolean(&name, b),
},
EditorCommand::CreateVariable {
name,
kind,
default_value,
} => {
let Some(kind) = parse_variable_kind(&kind) else {
return false;
};
// The default value is parsed per kind; a bad value
// (non-numeric Number, unparseable Boolean) rejects.
let default = match kind {
VariableKind::Color | VariableKind::String => {
VariableScalar::Str(default_value)
}
VariableKind::Number => match default_value.trim().parse::<f64>() {
Ok(n) => VariableScalar::Num(n),
Err(_) => return false,
},
VariableKind::Boolean => match default_value.trim() {
"true" => VariableScalar::Bool(true),
"false" => VariableScalar::Bool(false),
_ => return false,
},
};
self.create_variable(&name, kind, default)
}
EditorCommand::DeleteVariable { name } => self.delete_variable(&name),
EditorCommand::RenameVariable { old_name, new_name } => {
self.rename_variable(&old_name, &new_name)
}
EditorCommand::SetActiveAxisValue { axis, value } => {
self.set_active_axis_value(&axis, &value)
}
EditorCommand::CycleActiveAxisValue { axis } => self.cycle_active_axis_value(&axis),
// --- Pages ---------------------------------------------
EditorCommand::SetActivePage { index } => self.set_active_page(index as usize),
EditorCommand::AddPage => self.add_page().is_some(),
EditorCommand::RenamePage { index, name } => self.rename_page(index as usize, name),
EditorCommand::DeletePage { index } => self.remove_page(index as usize),
EditorCommand::DuplicatePage { index } => self.duplicate_page(index as usize).is_some(),
EditorCommand::ReorderPage { from, to } => {
self.reorder_page(from as usize, to as usize)
}
// --- Selection -----------------------------------------
EditorCommand::ClearSelection => {
self.clear_selection();
true
}
EditorCommand::SetSelection { node_id } => {
// Scoped to the active page — parity with shell-core,
// which rejected off-page ids so later reads stay
// consistent.
if !node_id.is_real() || find_node(self.active_children(), &node_id).is_none() {
return false;
}
self.set_single_selection(node_id);
true
}
EditorCommand::SetSelectionSet { node_ids } => {
// Resolve every id against the active page; unknown /
// off-page ids are dropped silently.
let resolved: Vec<NodeId> = node_ids
.into_iter()
.filter(|id| id.is_real() && find_node(self.active_children(), id).is_some())
.collect();
if resolved.is_empty() {
self.clear_selection();
} else {
self.selection.anchor = resolved.last().cloned().unwrap();
self.selection.set = resolved;
}
true
}
EditorCommand::ToggleNodeSelection { node_id } => {
if !node_id.is_real() || find_node(self.active_children(), &node_id).is_none() {
return false;
}
self.toggle_selection(node_id);
true
}
// --- Selection-scoped tree ops -------------------------
EditorCommand::DuplicateSelected { offset_px } => {
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
self.duplicate_selected(&mut next_id, offset_px as f64)
.is_some()
}
EditorCommand::DeleteSelected => {
if self.selection.set.is_empty() {
return false;
}
let snap = self.snapshot_for_history();
if self.delete_selected() {
self.history_push_past(snap);
true
} else {
false
}
}
EditorCommand::NudgeSelected { dx, dy } => {
if self.selection.set.is_empty() || (dx == 0 && dy == 0) {
return false;
}
let snap = self.snapshot_for_history();
self.translate_selected(dx as f64, dy as f64);
self.history_push_past(snap);
true
}
EditorCommand::GroupSelected => {
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let snap = self.snapshot_for_history();
if self.group_selected(&mut next_id).is_some() {
self.history_push_past(snap);
true
} else {
false
}
}
EditorCommand::UngroupSelected => {
let snap = self.snapshot_for_history();
if self.ungroup_selected() {
self.history_push_past(snap);
true
} else {
false
}
}
EditorCommand::ReorderSelected { direction } => {
if !self.selection.anchor.is_real() {
return false;
}
let snap = self.snapshot_for_history();
if self.reorder_selected(direction) {
self.history_push_past(snap);
true
} else {
false
}
}
EditorCommand::AlignSelected { action } => {
let Some(parsed) = parse_align_action(&action) else {
return false;
};
// `align_selected` pushes its own history on real
// motion.
self.align_selected(parsed)
}
// --- Clipboard -----------------------------------------
EditorCommand::CopySelected => self.copy_selected(),
EditorCommand::CutSelected => {
let snap = self.snapshot_for_history();
if self.cut_selected() {
self.history_push_past(snap);
true
} else {
false
}
}
EditorCommand::PasteClipboard { offset_px } => {
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let snap = self.snapshot_for_history();
let new_ids = self.paste_clipboard(&mut next_id, offset_px as f64);
if new_ids.is_empty() {
return false;
}
self.history_push_past(snap);
true
}
EditorCommand::ImportSvg { svg, x, y } => {
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
// `import_svg` pushes its own history snapshot when it
// inserts ≥ 1 node.
self.import_svg(&mut next_id, &svg, (x as f64, y as f64)) > 0
}
// --- Tool + viewport + history -------------------------
EditorCommand::SetActiveTool { tool } => {
let Some(new_tool) = parse_tool(&tool) else {
return false;
};
self.tool = new_tool;
true
}
EditorCommand::SetViewport {
pan_x,
pan_y,
zoom_percent,
} => {
let mut changed = false;
if let Some(x) = pan_x {
self.viewport.pan_x = x as f32;
changed = true;
}
if let Some(y) = pan_y {
self.viewport.pan_y = y as f32;
changed = true;
}
if let Some(z) = zoom_percent {
let zoom = (z as f32 / 100.0).clamp(Viewport::MIN_ZOOM, Viewport::MAX_ZOOM);
self.viewport.zoom = zoom;
changed = true;
}
changed
}
EditorCommand::Undo => self.undo(),
EditorCommand::Redo => self.redo(),
// --- Component commands — unsupported gap --------------
EditorCommand::InstantiateComponent { .. }
| EditorCommand::CreateComponent { .. }
| EditorCommand::DeleteComponent { .. }
| EditorCommand::RenameComponent { .. } => false,
// --- UIKit element insert -------------------------------
EditorCommand::InstantiateKitComponent {
kit_id,
component_id,
doc_x,
doc_y,
} => self
.instantiate_kit_component(
&kit_id,
&component_id,
doc_x.unwrap_or(0.0),
doc_y.unwrap_or(0.0),
)
.is_some(),
}
}
}