openpencil/crates/op-editor-core/src/command_node.rs

595 lines
20 KiB
Rust

//! Raw-node command application — `InsertNode` / `UpdateNode` /
//! `DeleteNode` / `MoveNode` / `CopyNode` / `ReplaceNode` /
//! `BatchInsert`.
//!
//! shell-core's `mcp_apply.rs` built its flat `Node` struct directly;
//! `op-editor-core` operates on the canonical `jian_ops_schema::PenNode`
//! tree, so this module ports the build / find / detach / replace
//! helpers onto `PenNode`. Carved off `command_apply.rs` to keep both
//! files under the 800-line cap.
//!
//! Every helper preserves the pre-validate-then-mutate discipline: a
//! caller validates kind / geometry / hex / id space BEFORE any tree
//! write, so a bad arg never leaves the document half-mutated.
use crate::command::BatchInsertItem;
use crate::fills::set_primary_fill_hex;
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use crate::state::EditorState;
use crate::walkers;
use jian_ops_schema::node::{
ContainerProps, EllipseNode, FrameNode, GroupNode, LineNode, PathNode, PenNode, PenNodeBase,
PolygonNode, RectangleNode, TextContent, TextNode,
};
use jian_ops_schema::sizing::SizingBehavior;
use std::collections::HashSet;
/// Resolve an editor `kind` arg into a canonical-schema leaf node.
/// Accepts the same lowercase strings the read-side tools emit
/// (`frame` / `group` / `rect` / `ellipse` / `polygon` / `line` /
/// `text` / `path`). `None` for an unknown kind.
///
/// `width` / `height` write the variant's literal `SizingBehavior`;
/// `(x, y)` write `base`. Container kinds (`frame` / `group`) start
/// with an empty `children` so a follow-up `MoveNode` can reparent
/// into them.
pub fn build_leaf_node(
kind: &str,
id: &str,
name: &str,
x: i32,
y: i32,
width: i32,
height: i32,
) -> Option<PenNode> {
let base = PenNodeBase {
id: id.to_string(),
name: Some(name.to_string()),
x: Some(x as f64),
y: Some(y as f64),
..Default::default()
};
let w = SizingBehavior::Number(width.max(0) as f64);
let h = SizingBehavior::Number(height.max(0) as f64);
let node = match kind {
"frame" => PenNode::Frame(FrameNode {
base,
container: ContainerProps {
width: Some(w),
height: Some(h),
..Default::default()
},
children: Some(Vec::new()),
image_search_query: None,
reusable: None,
slot: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"group" => PenNode::Group(GroupNode {
base,
container: ContainerProps {
width: Some(w),
height: Some(h),
..Default::default()
},
children: Some(Vec::new()),
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"rect" => PenNode::Rectangle(RectangleNode {
base,
container: ContainerProps {
width: Some(w),
height: Some(h),
..Default::default()
},
children: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"ellipse" => PenNode::Ellipse(EllipseNode {
base,
width: Some(w),
height: Some(h),
corner_radius: None,
inner_radius: None,
start_angle: None,
sweep_angle: None,
fill: None,
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"polygon" => PenNode::Polygon(PolygonNode {
base,
polygon_count: 3,
width: Some(w),
height: Some(h),
corner_radius: None,
fill: None,
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"line" => PenNode::Line(LineNode {
base,
x2: Some((x + width) as f64),
y2: Some((y + height) as f64),
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"text" => PenNode::Text(TextNode {
base,
width: Some(w),
height: Some(h),
content: TextContent::Plain(name.to_string()),
font_family: None,
font_size: None,
font_weight: None,
font_style: None,
letter_spacing: None,
line_height: None,
text_align: None,
text_align_vertical: None,
text_growth: None,
underline: None,
strikethrough: None,
fill: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"path" => PenNode::Path(PathNode {
base,
icon_id: None,
d: None,
anchors: Some(Vec::new()),
closed: None,
width: Some(w),
height: Some(h),
fill: None,
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
_ => return None,
};
Some(node)
}
/// True when `kind` resolves to a buildable leaf node. Used by the
/// `BatchInsert` pre-validation pass.
pub fn kind_is_valid(kind: &str) -> bool {
matches!(
kind,
"frame" | "group" | "rect" | "ellipse" | "polygon" | "line" | "text" | "path"
)
}
/// Replace the node with `target` id with `replacement` at its current
/// slot, preserving sibling order. True on the first match.
pub fn replace_node_in_children(
children: &mut [PenNode],
target: &NodeId,
replacement: &mut Option<PenNode>,
) -> bool {
if let Some(idx) = children.iter().position(|n| n.id_str() == target.as_str()) {
if let Some(r) = replacement.take() {
children[idx] = r;
return true;
}
}
for child in children.iter_mut() {
if let Some(grand) = child.children_mut() {
if replace_node_in_children(grand, target, replacement) {
return true;
}
}
}
false
}
impl EditorState {
/// Compute the numeric seed for the next editor-minted `n{N}` id —
/// `max_node_id() + 1`. `None` on `u64` exhaustion.
pub(crate) fn next_node_id_seed(&self) -> Option<u64> {
self.max_node_id().checked_add(1).map(|n| n.max(1))
}
/// Allocate one fresh `n{N}` id, skipping any candidate that
/// collides with a live id. `None` on counter exhaustion.
pub(crate) fn next_node_id(&self) -> Option<NodeId> {
let mut seed = self.next_node_id_seed()?;
let mut live = self.collect_node_ids();
walkers::alloc_n_id(&mut seed, &mut live)
}
/// `InsertNode` — build + append a fresh leaf on the active page.
// Args mirror the `InsertNode` command fields one-for-one; bundling
// them into a struct would just shadow the DTO with no real gain.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_insert_node(
&mut self,
kind: &str,
name: &str,
x: i32,
y: i32,
width: i32,
height: i32,
fill_hex: &Option<String>,
) -> bool {
if !kind_is_valid(kind) || width < 0 || height < 0 {
return false;
}
// Pre-validate hex BEFORE minting an id / mutating the tree.
if let Some(hex) = fill_hex {
if crate::color_picker::parse_hex_rgb(hex).is_none() {
return false;
}
}
let Some(new_id) = self.next_node_id() else {
return false;
};
let Some(mut node) = build_leaf_node(kind, new_id.as_str(), name, x, y, width, height)
else {
return false;
};
if let Some(hex) = fill_hex {
set_primary_fill_hex(&mut node, hex);
}
self.active_children_mut().push(node);
true
}
/// `UpdateNode` — patch optional fields on an existing node.
// Args mirror the `UpdateNode` command fields one-for-one.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_update_node(
&mut self,
node_id: &NodeId,
x: Option<i32>,
y: Option<i32>,
width: Option<i32>,
height: Option<i32>,
name: &Option<String>,
fill_hex: &Option<String>,
) -> bool {
if !node_id.is_real() {
return false;
}
// Pre-validate EVERY field before the mutable borrow + writes.
if let Some(w) = width {
if w < 0 {
return false;
}
}
if let Some(h) = height {
if h < 0 {
return false;
}
}
if let Some(hex) = fill_hex {
if crate::color_picker::parse_hex_rgb(hex).is_none() {
return false;
}
}
let Some(node) = walkers::find_node_mut(self.active_children_mut(), node_id) else {
return false;
};
// All validation passed — every field applies atomically.
if let Some(nx) = x {
node.base_mut().x = Some(nx as f64);
}
if let Some(ny) = y {
node.base_mut().y = Some(ny as f64);
}
if let Some(nw) = width {
node.set_width_px(nw as f64);
}
if let Some(nh) = height {
node.set_height_px(nh as f64);
}
if let Some(new_name) = name {
node.base_mut().name = Some(new_name.clone());
}
if let Some(hex) = fill_hex {
set_primary_fill_hex(node, hex);
}
true
}
/// `DeleteNode` — remove a node + descendants from the active page.
pub(crate) fn cmd_delete_node(&mut self, node_id: &NodeId) -> bool {
if !node_id.is_real() {
return false;
}
walkers::remove_from_children(self.active_children_mut(), node_id)
}
/// `MoveNode` — reparent a node. A `NONE` target reparents to the
/// active page root; a real target must resolve + must not create
/// a cycle (target is a descendant of the moved node).
pub(crate) fn cmd_move_node(&mut self, node_id: &NodeId, target_parent: &NodeId) -> bool {
if !node_id.is_real() || target_parent == node_id {
return false;
}
// Pre-validate EVERYTHING before detaching: a bad target would
// detach → reattach-fail → silently drop the source.
{
let children = self.active_children();
let Some(src) = walkers::find_node(children, node_id) else {
return false;
};
if target_parent.is_real() {
if walkers::descendant_contains(src, target_parent) {
return false;
}
if walkers::find_node(children, target_parent).is_none() {
return false;
}
}
}
let children = self.active_children_mut();
let Some(detached) = walkers::extract_node(children, node_id) else {
return false;
};
if target_parent.is_real() {
walkers::append_into(children, target_parent, detached).is_ok()
} else {
children.push(detached);
true
}
}
/// `CopyNode` — deep-clone a node + subtree under a new parent
/// (`NONE` = active page root). Fresh ids minted past the id space.
pub(crate) fn cmd_copy_node(&mut self, node_id: &NodeId, target_parent: &NodeId) -> bool {
if !node_id.is_real() {
return false;
}
// Validate source + target up front.
{
let children = self.active_children();
if walkers::find_node(children, node_id).is_none() {
return false;
}
if target_parent.is_real() && walkers::find_node(children, target_parent).is_none() {
return false;
}
}
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let mut taken = self.collect_node_ids();
// Clone the owned subtree before re-borrowing the tree mutably.
let clone = {
let children = self.active_children();
let src = walkers::find_node(children, node_id).expect("validated");
walkers::deep_clone_with_new_ids(src, &mut next_id, &mut taken)
};
let children = self.active_children_mut();
if target_parent.is_real() {
walkers::append_into(children, target_parent, clone).is_ok()
} else {
children.push(clone);
true
}
}
/// `ReplaceNode` — swap an existing node for a freshly-built leaf at
/// the same slot. The destructive-swap guard: replacing a node WITH
/// children requires `drop_children == true`, else the swap is
/// refused so a container can't silently lose its subtree.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_replace_node(
&mut self,
node_id: &NodeId,
kind: &str,
name: &str,
x: i32,
y: i32,
width: i32,
height: i32,
fill_hex: &Option<String>,
drop_children: bool,
) -> bool {
if !node_id.is_real() || !kind_is_valid(kind) || width < 0 || height < 0 {
return false;
}
if let Some(hex) = fill_hex {
if crate::color_picker::parse_hex_rgb(hex).is_none() {
return false;
}
}
// Resolve target + check the destructive-swap guard BEFORE
// minting an id. A target WITH children needs explicit consent.
{
let children = self.active_children();
let Some(target) = walkers::find_node(children, node_id) else {
return false;
};
let has_children = target.children().map(|c| !c.is_empty()).unwrap_or(false);
if has_children && !drop_children {
return false;
}
}
let Some(new_id) = self.next_node_id() else {
return false;
};
let Some(mut replacement) =
build_leaf_node(kind, new_id.as_str(), name, x, y, width, height)
else {
return false;
};
if let Some(hex) = fill_hex {
set_primary_fill_hex(&mut replacement, hex);
}
let mut slot = Some(replacement);
replace_node_in_children(self.active_children_mut(), node_id, &mut slot)
}
/// `BatchInsert` — insert N leaf nodes on the active page in one
/// atomic shot. EVERY descriptor is validated before any mutation;
/// a single bad entry rejects the whole batch.
pub(crate) fn cmd_batch_insert(&mut self, items: &[BatchInsertItem]) -> bool {
if items.is_empty() {
return false;
}
// Pre-validate kinds + geometry + fill hex up front.
for item in items {
if !kind_is_valid(&item.kind) || item.width < 0 || item.height < 0 {
return false;
}
if let Some(hex) = &item.fill_hex {
if crate::color_picker::parse_hex_rgb(hex).is_none() {
return false;
}
}
}
// Allocate every fresh id up front; bail on id-space exhaustion.
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let mut live: HashSet<NodeId> = self.collect_node_ids();
let mut ids: Vec<NodeId> = Vec::with_capacity(items.len());
for _ in 0..items.len() {
match walkers::alloc_n_id(&mut next_id, &mut live) {
Some(id) => ids.push(id),
None => return false,
}
}
// All validation + allocation passed — now mutate.
let children = self.active_children_mut();
for (item, id) in items.iter().zip(ids) {
// `kind` already validated, so `build_leaf_node` is Some.
let mut node = build_leaf_node(
&item.kind,
id.as_str(),
&item.name,
item.x,
item.y,
item.width,
item.height,
)
.expect("kind validated");
if let Some(hex) = &item.fill_hex {
set_primary_fill_hex(&mut node, hex);
}
children.push(node);
}
true
}
/// Insert one or more nested `PenNode` subtrees. `parent_id` of
/// `NONE` appends to the active page root; otherwise the parent
/// must exist and be a container variant. Every incoming node id
/// (recursively) is remapped to a fresh editor id so an
/// externally-authored subtree can't collide with live doc ids.
pub(crate) fn cmd_insert_subtree(&mut self, nodes: Vec<PenNode>, parent_id: &NodeId) -> bool {
if nodes.is_empty() {
return false;
}
// Validate the parent up front (when not the page root).
if parent_id.is_real() {
match walkers::find_node(self.active_children(), parent_id) {
Some(p) if p.is_container() => {}
_ => return false, // missing or non-container
}
}
// Allocate fresh ids for the whole incoming forest.
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let mut taken: HashSet<NodeId> = self.collect_node_ids();
let mut nodes = nodes;
if !remap_subtree_ids(&mut nodes, &mut next_id, &mut taken) {
return false;
}
// All validation + allocation passed — now mutate.
if parent_id.is_real() {
let root = self.active_children_mut();
let Some(parent) = walkers::find_node_mut(root, parent_id) else {
return false;
};
let Some(slot) = parent.children_mut() else {
return false;
};
slot.extend(nodes);
} else {
self.active_children_mut().extend(nodes);
}
true
}
}
/// Reassign every node id in `nodes` (recursively, including
/// `children`) to a fresh unique id. `next_id` + `taken` are the same
/// allocator pair [`walkers::alloc_n_id`] uses; `taken` must be seeded
/// with the document's live ids. Returns `false` on id-space
/// exhaustion.
pub(crate) fn remap_subtree_ids(
nodes: &mut [PenNode],
next_id: &mut u64,
taken: &mut HashSet<NodeId>,
) -> bool {
for node in nodes.iter_mut() {
let Some(fresh) = walkers::alloc_n_id(next_id, taken) else {
return false;
};
node.base_mut().id = fresh.as_str().to_string();
if let Some(children) = node.children_mut() {
if !remap_subtree_ids(children, next_id, taken) {
return false;
}
}
}
true
}