//! 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 { 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, ) -> 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 { 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 { 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, ) -> 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, y: Option, width: Option, height: Option, name: &Option, fill_hex: &Option, ) -> 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, 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 = self.collect_node_ids(); let mut ids: Vec = 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, 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 = 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, ) -> 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 }