git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/window.rs (33.9K)

  1 //! The designer's window-event layer on the cce-ui engine.
  2 //!
  3 //! The Wayland plumbing (seat/pointer/keyboard handlers, configure, CSD)
  4 //! lives in cce-ui's window runner; the `Application` impl (application.rs)
  5 //! translates the runner's hooks into [`WindowEvent`]s. What remains here is
  6 //! app policy: the post-event side-effect pass (`process_window_event`) and
  7 //! MCP-action application (`apply_custom_event`).
  8 
  9 use std::path::Path;
 10 
 11 use cce_ui::widget::WidgetHost;
 12 use crate::app::{State, CustomEvent, McpAction, Project, ParamDef};
 13 use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, PARAM_IDX, WIDGET_COUNT};
 14 
 15 #[derive(Debug, Clone, Copy)]
 16 pub struct LocalPosition {
 17     pub x: f64,
 18     pub y: f64,
 19 }
 20 
 21 pub enum WindowEvent {
 22     MouseWheel { delta: cce_ui::widget::MouseScrollDelta },
 23     CursorMoved { position: LocalPosition },
 24     MouseInput { state: cce_ui::widget::ElementState, button: cce_ui::widget::MouseButton },
 25     KeyboardInput { event: cce_ui::widget::KeyEvent },
 26 }
 27 
 28 /// The menubar menus `process_window_event` still dispatches by index: the
 29 /// viewport's Camera menu and the parameters' Preset and Reset, which run
 30 /// what the camera and preset commands run. The rest of what the menubars
 31 /// list is reached as a registry command.
 32 pub(crate) fn menu_is_dispatched(widget_idx: usize, menu_idx: usize) -> bool {
 33     match widget_idx {
 34         RIGHT_MENUBAR_IDX => menu_idx == 0,
 35         PARAM_MENUBAR_IDX => menu_idx <= 1,
 36         _ => false,
 37     }
 38 }
 39 
 40 impl State {
 41     /// Route a window event through `handle_event`, then run the post-event
 42     /// side-effect pass (menu clicks, pane toggles, pending actions).
 43     /// Returns true when a redraw is needed.
 44     pub(crate) fn process_window_event(&mut self, ev: WindowEvent) -> bool {
 45         let mut result = false;
 46         {
 47             let state = &mut *self;
 48             let mut changed = state.handle_event(&ev);
 49 
 50             if let Some(seg) = state.path_mut().path_click() {
 51                 if seg < state.current_path.len() {
 52                     let exited_idx = state.current_path.get(seg).copied();
 53                     state.current_path.truncate(seg);
 54                     state.on_path_changed();
 55                     if let Some(idx) = exited_idx {
 56                         let pos = {
 57                             let dir = state.current_dir();
 58                             if idx < dir.children.len() {
 59                                 Some(dir.children[idx].position)
 60                             } else {
 61                                 None
 62                             }
 63                         };
 64                         if let Some((pos_x, pos_y)) = pos {
 65                             state.grid_cursor_col = pos_x as i32;
 66                             state.grid_cursor_row = pos_y as i32;
 67                             state.sync_cursor_and_selection();
 68                         }
 69                     }
 70                     changed = true;
 71                 }
 72             }
 73 
 74             if let Some(id) = state.pending_command.take() {
 75                 state.run_command(id);
 76                 changed = true;
 77             }
 78 
 79 
 80             // Check context switcher dropdown changes
 81             for &widget_idx in &[HEADER_IDX, LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX] {
 82                 if let Some(new_sel) = state.menu_mut(widget_idx).take_context_change() {
 83                     let target_pane = match new_sel {
 84                         0 => LEFT_MENUBAR_IDX,
 85                         1 => RIGHT_MENUBAR_IDX,
 86                         2 => PARAM_MENUBAR_IDX,
 87                         3 => SPREADSHEET_MENUBAR_IDX,
 88                         4 => HEADER_IDX,
 89                         _ => continue,
 90                     };
 91                     if state.focused_pane != target_pane {
 92                         state.focused_pane = target_pane;
 93                         // Make sure the switched pane is visible!
 94                         match target_pane {
 95                             LEFT_MENUBAR_IDX => {
 96                                 if !state.show_network {
 97                                     state.show_network = true;
 98                                     state.ui_context[state.slots.content].set_visible(true);
 99                                     state.ui_context[state.slots.left_menubar].set_visible(true);
100                                     state.ui_context[state.slots.breadcrumb].set_visible(true);
101                                     state.menu_mut(HEADER_IDX).set_item_checked(2, 4, true);
102                                 }
103                             }
104                             RIGHT_MENUBAR_IDX => {
105                                 if !state.show_viewport {
106                                     state.show_viewport = true;
107                                     state.ui_context[state.slots.viewport].set_visible(true);
108                                     state.ui_context[state.slots.right_menubar].set_visible(true);
109                                     state.menu_mut(HEADER_IDX).set_item_checked(2, 5, true);
110                                 }
111                             }
112                             PARAM_MENUBAR_IDX => {
113                                 if !state.show_parameters {
114                                     state.show_parameters = true;
115                                     state.ui_context[state.slots.param].set_visible(true);
116                                     state.ui_context[state.slots.param_menubar].set_visible(true);
117                                     state.menu_mut(HEADER_IDX).set_item_checked(2, 6, true);
118                                 }
119                             }
120                             SPREADSHEET_MENUBAR_IDX => {
121                                 if !state.show_spreadsheet {
122                                     state.show_spreadsheet = true;
123                                     state.ui_context[state.slots.spreadsheet].set_visible(true);
124                                     state.ui_context[state.slots.spreadsheet_menubar].set_visible(true);
125                                     state.menu_mut(HEADER_IDX).set_item_checked(2, 7, true);
126                                 }
127                             }
128                             _ => {}
129                         }
130                         state.rebuild_positions();
131                         state.apply_layout();
132                         state.sync_pane_focus();
133                         state.sync_nodes();
134                         changed = true;
135                     }
136                 }
137             }
138 
139             // The menubars are not drawn (their bars have no height), so a
140             // click reaches these only through MCP's `menu_click`. What is
141             // dispatched here is the two menus whose items are chosen by
142             // position: the cameras, and the parameter presets. Both run what
143             // their commands run. Everything else the menubars list is
144             // reached as a command.
145             if let Some((menu_idx, item_idx)) = state.menu_mut(RIGHT_MENUBAR_IDX).menu_click() {
146                 if menu_idx == 0 {
147                     if let Some(name) = state.camera_names().get(item_idx).cloned() {
148                         changed |= state.choose_camera(&name);
149                     }
150                 }
151             }
152 
153             if let Some((menu_idx, item_idx)) = state.menu_mut(PARAM_MENUBAR_IDX).menu_click() {
154                 // Preset's Default and Reset's All are one thing.
155                 if menu_idx <= 1 && item_idx == 0 {
156                     changed |= state.reset_parameters();
157                 }
158             }
159 
160             if changed {
161                 state.sync_layout();
162                 state.read_panel_offsets();
163                 state.sync_cursor_and_selection();
164 
165                 if state.drag_widget == Some(PARAM_IDX) && state.ui_context[state.slots.param].is_dragging() {
166                     state.sync_parameters_to_project();
167                 }
168 
169                 state.sync_nodes();
170 
171                 // Sync Parameters pane with selected node — through the one
172                 // pane-sync path, so textpick rows survive this rebuild.
173                 state.sync_parameters_pane();
174 
175             }
176 
177             if changed {
178                 // Once a frame, not once an event: see `title_dirty`.
179                 state.title_dirty = true;
180                 if state.syncing_windows() {
181                     state.needs_autosave = true;
182                 }
183                 result = true;
184             }
185             // Whatever the event did to the graph is a step of undo.
186             state.record_structure_changes();
187         }
188         result
189     }
190 
191     /// Apply an automation event (the engine `update` hook). Returns true
192     /// when a redraw is needed.
193     pub(crate) fn apply_custom_event(&mut self, event: CustomEvent) -> bool {
194         let mut needs_redraw = false;
195         {
196             let state = &mut *self;
197             match event {
198                 CustomEvent::McpCall(call) => {
199                     let res = state.apply_mcp_call(&call, &mut needs_redraw);
200                     let _ = call.reply.send(res);
201                 }
202                 CustomEvent::RunAction(action) => {
203                     if let Err(e) = state.apply_action(action, &mut needs_redraw) {
204                         state.update_status_text(&e);
205                         needs_redraw = true;
206                     }
207                 }
208                 // Exit is handled by the Application::update wrapper
209                 // (autosave + engine exit) before this is reached.
210                 CustomEvent::Exit => {}
211             }
212         }
213         if needs_redraw {
214             self.update_window_title();
215             if self.syncing_windows() {
216                 self.needs_autosave = true;
217             }
218         }
219         needs_redraw
220     }
221 
222     /// Snapshot the project (node tree + view state) for state queries.
223     fn project_snapshot(&self) -> Project {
224         Project {
225             name: "Project".to_string(),
226             root: self.fs_root.clone(),
227             view_state: self.project_view_state(),
228             format: crate::app::PROJECT_FORMAT,
229         }
230     }
231 
232     /// An MCP tool call: `get_state` returns the project snapshot; every
233     /// other tool name is an `McpAction` tag — injected into the arguments
234     /// and run through the shared action path.
235     pub(crate) fn apply_mcp_call(
236         &mut self,
237         call: &cce_ui::mcp::McpToolCall,
238         needs_redraw: &mut bool,
239     ) -> Result<serde_json::Value, String> {
240         if call.name == "get_state" {
241             let mut v = serde_json::to_value(self.project_snapshot())
242                 .map_err(|e| format!("failed to serialize state: {e}"))?;
243             // Additive sibling of the project fields: the playbar is app
244             // state, not project state, so it must not enter the Project
245             // struct (the save format) — but automation needs to read it.
246             let pb = self.ui_context[self.slots.playbar].inner();
247             v["playbar"] = serde_json::json!({
248                 "frame": pb.current_frame.round() as i64,
249                 "playing": pb.playing,
250                 "reversed": pb.reversed,
251                 "repeat": pb.repeat,
252                 "step_buttons": pb.step_buttons,
253                 "fps": pb.fps,
254                 "start_frame": pb.start_frame.round() as i64,
255                 "end_frame": pb.end_frame.round() as i64,
256             });
257             // The network grid as it is right now, beside what config says
258             // it is at 100%: the one way to check, from outside, that a
259             // config edit reached the lattice on screen.
260             let cfg = crate::app::configured_grid_geometry();
261             v["grid"] = serde_json::json!({
262                 "pitch": [self.grid_pitch_x, self.grid_pitch_y],
263                 "node_size": [self.node_w, self.node_h],
264                 "zoom_percent": self.zoom_percent(),
265                 "configured_pitch": [cfg.pitch_x, cfg.pitch_y],
266                 "configured_node_size": [cfg.node_w, cfg.node_h],
267             });
268             // The status line as shown — the load report, a node error, a
269             // refused edit. The window may be anywhere, or off-screen; this
270             // is how to read it from outside.
271             v["status"] = serde_json::Value::String(self.last_status_text.clone());
272             return Ok(v);
273         }
274         let mut req = if call.arguments.is_object() {
275             call.arguments.clone()
276         } else {
277             serde_json::json!({})
278         };
279         req["action"] = serde_json::Value::String(call.name.clone());
280         match serde_json::from_value::<McpAction>(req) {
281             Ok(action) => self
282                 .apply_action(action, needs_redraw)
283                 .map(serde_json::Value::String),
284             Err(e) => Err(format!("invalid arguments for '{}': {e}", call.name)),
285         }
286     }
287 
288     /// Apply one automation action (an MCP tool call, or an app-internal
289     /// fire-and-forget `RunAction`).
290     pub(crate) fn apply_action(
291         &mut self,
292         action: McpAction,
293         redraw: &mut bool,
294     ) -> Result<String, String> {
295         let mut needs_redraw = false;
296         let state = self;
297         let res = match action {
298             McpAction::Up => {
299                 if state.move_up() {
300                     // on_path_changed clears the selection but leaves the param
301                     // pane to process_window_event's tail, which MCP bypasses.
302                     state.sync_parameters_pane();
303                     needs_redraw = true;
304                     Ok("Moved up".to_string())
305                 } else {
306                     Err("Already at root".to_string())
307                 }
308             }
309             McpAction::Enter { slot } => {
310                 let dir = state.current_dir();
311                 if slot < dir.children.len() && dir.children[slot].is_enterable() {
312                     state.current_path.push(slot);
313                     state.on_path_changed();
314                     state.sync_parameters_pane();
315                     needs_redraw = true;
316                     Ok("Entered subnet".to_string())
317                 } else {
318                     Err("Not a valid subnet".to_string())
319                 }
320             }
321             McpAction::Select { slot } => {
322                 if slot < state.current_dir().children.len() {
323                     // Same effect as clicking the node: it becomes the selected node and
324                     // its params populate the parameter pane. A click also reaches
325                     // sync_nodes via process_window_event's changed-path, which is
326                     // what refreshes the spreadsheet — this path must call it itself.
327                     state.graph_mut().set_selected_node(Some(slot));
328                     state.sync_parameters_pane();
329                     state.sync_nodes();
330                     needs_redraw = true;
331                     Ok("Node selected".to_string())
332                 } else {
333                     Err("Slot index out of bounds".to_string())
334                 }
335             }
336             McpAction::SetParam { slot, name, value } => {
337                 let dir = state.current_dir_mut();
338                 if let Some(child) = dir.children.get_mut(slot) {
339                     let node_id = child.id.clone();
340                     if let Some(p) = crate::app::param_by_name_or_label(&mut child.params, &name) {
341                         let name = p.name.clone();
342                         let before = p.clone();
343                         // A value that reads as a reference becomes an
344                         // expression, as one typed into the pane does; an
345                         // expression is checked when it evaluates. Anything
346                         // else must fit the kind, or nothing is written.
347                         let as_expr = p.is_expr() || (p.takes_expressions() && crate::expr::looks_like_expression(&value));
348                         if !as_expr {
349                             if let Err(why) = p.check(&value) {
350                                 return Err(format!("{name}: {why}"));
351                             }
352                         }
353                         p.set_text(value);
354                         if as_expr {
355                             p.set_expr(true);
356                         }
357                         // A page's Preset and Units set its
358                         // Width and Height, and the step holds all of it.
359                         let followed = if child.node_type == "page" {
360                             crate::page::follow_page_rows(child, &before)
361                         } else {
362                             Vec::new()
363                         };
364                         if followed.is_empty() {
365                             state.record_param_edit(&node_id, before);
366                         } else {
367                             let what = before.name.clone();
368                             let params = std::iter::once(before).chain(followed).collect();
369                             state.record_params(crate::edit_history::ParamSnapshot { node_id, what, params }, false);
370                         }
371                         // Same sequence as the interactive param-pane
372                         // path, so settings params (viewport flags,
373                         // grid) actually take effect via automation.
374                         state.sync_grid_settings();
375                         state.sync_nodes();
376                         state.rebuild_scene_geometry();
377                         // Interactively the edit originates IN the param pane;
378                         // here it must be pushed back or a selected node's pane
379                         // keeps showing the old value.
380                         state.sync_parameters_pane();
381                         needs_redraw = true;
382                         Ok("Parameter updated".to_string())
383                     } else {
384                         Err(format!("Parameter {} not found", name))
385                     }
386                 } else {
387                     Err("Slot index out of bounds".to_string())
388                 }
389             }
390             McpAction::ResetCamera => {
391                 if state.active_camera != "Default Camera" {
392                     state.update_active_camera_rotation_reset();
393                 } else {
394                     state.viewport_mut().rotation_y = 0.0;
395                     state.viewport_mut().rotation_x = 0.0;
396                 }
397                 state.viewport_mut().zoom = 1.0;
398                 state.viewport_mut().reset_velocity();
399                 needs_redraw = true;
400                 Ok("Camera reset".to_string())
401             }
402             McpAction::Load { path } => {
403                 if let Err(e) = state.load_from_file(Path::new(&path)) {
404                     Err(format!("Load failed: {:?}", e))
405                 } else {
406                     needs_redraw = true;
407                     Ok("Project loaded".to_string())
408                 }
409             }
410             McpAction::Save { path } => {
411                 if let Err(e) = state.save_to_file(Path::new(&path)) {
412                     Err(format!("Save failed: {:?}", e))
413                 } else {
414                     let path_buf = Path::new(&path).to_path_buf();
415                     state.loaded_project_path = Some(path_buf.clone());
416                     state.add_recent_file(path_buf);
417                     needs_redraw = true;
418                     Ok("Project saved".to_string())
419                 }
420             }
421             McpAction::ToggleBypass { slot } => {
422                 match state.current_dir().children.get(slot).map(|n| !n.bypassed) {
423                     Some(bypassed) => {
424                         state.set_bypassed(&[slot], bypassed);
425                         needs_redraw = true;
426                         Ok(format!("Bypassed: {}", bypassed))
427                     }
428                     None => Err("Slot out of bounds".to_string()),
429                 }
430             }
431             McpAction::ToggleGeometry { slot } => {
432                 let active_nodes = state.current_dir().children.len();
433                 if slot < active_nodes {
434                     {
435                         let visible = !state.current_dir().children[slot].geometry_visible;
436                         state.current_dir_mut().set_child_geometry_visible(slot, visible);
437                         state.sync_nodes();
438                         state.rebuild_scene_geometry();
439                         needs_redraw = true;
440                         Ok(format!("Geometry visible: {}", visible))
441                     }
442                 } else {
443                     Err("Slot out of bounds".to_string())
444                 }
445             }
446             McpAction::AddNode { template_name, name, x, y } => {
447                 let template_idx = state.node_templates.iter().position(|t| {
448                     t.label.to_lowercase() == template_name.to_lowercase()
449                         || t.node.name.to_lowercase() == template_name.to_lowercase()
450                 });
451                 let here = crate::context::context_at(&state.current_path);
452                 let refused = template_idx.and_then(|idx| {
453                     let t = &state.node_templates[idx];
454                     crate::context::refusal(&t.label, &t.node.node_type, here)
455                 });
456                 if let Some(why) = refused {
457                     state.update_status_text(&why);
458                     Err(why)
459                 } else if let Some(idx) = template_idx {
460                     let mut node = state.node_templates[idx].node.clone();
461                     // Fresh ids, like paste: a verbatim clone shares the
462                     // template's ids across every instance.
463                     crate::app::regenerate_node_ids(&mut node);
464                     {
465                         let (nx, ny) = state.find_empty_cell(x, y, None);
466                         node.position = (nx, ny);
467                         if let Some(n) = name {
468                             node.name = crate::app::sanitize_node_name(&n);
469                         } else {
470                             node.name = state.get_lowest_unused_name(&node.name);
471                         }
472                         // New nodes arrive with their display flag OFF: the
473                         // one-visible-per-directory rule means showing is an
474                         // explicit act ('e', the click toggle), never a side
475                         // effect of adding. Top-level flag only — a subnet
476                         // template's internal chain keeps its own flags.
477                         node.geometry_visible = false;
478                         state.current_dir_mut().children.push(node);
479                         state.sync_nodes();
480                         state.rebuild_positions();
481                         state.apply_layout();
482                         state.update_panel_bounds();
483                         state.rebuild_scene_geometry();
484                         needs_redraw = true;
485                         Ok("Node added".to_string())
486                     }
487                 } else {
488                     Err(format!("Template '{}' not found", template_name))
489                 }
490             }
491             McpAction::DeleteNode { slot } => {
492                 if state.delete_node(slot) {
493                     // delete_node clears/shifts the selection; the param pane
494                     // resync normally comes from process_window_event's tail.
495                     state.sync_parameters_pane();
496                     needs_redraw = true;
497                     Ok("Node deleted".to_string())
498                 } else {
499                     Err("Slot out of bounds".to_string())
500                 }
501             }
502             McpAction::RenameNode { slot, new_name } => {
503                 match state.current_dir().children.get(slot).map(|n| n.id.clone()) {
504                     Some(id) => {
505                         let res = state.rename_node(&id, &new_name);
506                         needs_redraw |= res.is_ok();
507                         // The reply body is interpolated into JSON unescaped.
508                         res.map(|_| "Node renamed".to_string())
509                     }
510                     None => Err("Slot out of bounds".to_string()),
511                 }
512             }
513             McpAction::MoveNode { slot, x, y } => {
514                 let len = state.current_dir().children.len();
515                 if slot < len {
516                     let (nx, ny) = state.find_empty_cell(x, y, Some(slot));
517                     state.current_dir_mut().children[slot].position = (nx, ny);
518                     state.sync_nodes();
519                     state.rebuild_positions();
520                     state.apply_layout();
521                     state.update_panel_bounds();
522                     needs_redraw = true;
523                     Ok("Node moved".to_string())
524                 } else {
525                     Err("Slot out of bounds".to_string())
526                 }
527             }
528             McpAction::AddParam { slot, name, param_type, default, label } => {
529                 let len = state.current_dir().children.len();
530                 if !crate::app::is_param_name(&name) {
531                     Err(format!(
532                         "'{name}' is not a parameter name: lowercase letters, digits and underscores, as in '{}' — the pane shows the label, which may say anything",
533                         crate::app::param_name_of(&name)
534                     ))
535                 } else if state.current_dir().children.get(slot).is_some_and(|c| c.params.iter().any(|p| p.name == name)) {
536                     Err(format!("{name}: the node already has a parameter of that name"))
537                 } else if crate::app::ParamKind::parse(&param_type).is_none() {
538                     Err(format!(
539                         "Unknown param_type '{param_type}'; expected one of: {}",
540                         crate::app::ParamKind::NAMES.join(", ")
541                     ))
542                 } else if let Some(why) = ParamDef::new(name.clone(), param_type.clone(), default.clone()).invalid() {
543                     Err(format!("{name}: {why}"))
544                 } else if slot < len {
545                     let param = ParamDef::new(name, param_type, default).with_label(label);
546                     state.current_dir_mut().children[slot].params.push(param);
547                     state.sync_nodes();
548                     // Params feed kernel evaluation and the param pane shows
549                     // the selected node's list — same rationale as SetParam.
550                     state.rebuild_scene_geometry();
551                     state.sync_parameters_pane();
552                     needs_redraw = true;
553                     Ok("Parameter added".to_string())
554                 } else {
555                     Err("Slot out of bounds".to_string())
556                 }
557             }
558             McpAction::DeleteParam { slot, name } => {
559                 let len = state.current_dir().children.len();
560                 if slot < len {
561                     let params = &mut state.current_dir_mut().children[slot].params;
562                     let found = crate::app::param_by_name_or_label(params, &name).map(|p| p.name.clone());
563                     if let Some(pos) = found.and_then(|n| params.iter().position(|p| p.name == n)) {
564                         params.remove(pos);
565                         state.sync_nodes();
566                         state.rebuild_scene_geometry();
567                         state.sync_parameters_pane();
568                         needs_redraw = true;
569                         Ok("Parameter deleted".to_string())
570                     } else {
571                         Err(format!("Parameter '{}' not found", name))
572                     }
573                 } else {
574                     Err("Slot out of bounds".to_string())
575                 }
576             }
577             McpAction::SetPaneCollapsed { pane, collapsed } => {
578                 let idx = crate::plate_menu::pane_slot_from_name(&pane)
579                     .ok_or_else(|| format!("unknown pane: {pane}"))?;
580                 state.set_pane_collapsed(idx, collapsed);
581                 needs_redraw = true;
582                 Ok(format!("{pane} collapsed={collapsed}"))
583             }
584             McpAction::SetPaneDetached { pane, detached } => {
585                 let idx = crate::plate_menu::pane_slot_from_name(&pane)
586                     .ok_or_else(|| format!("unknown pane: {pane}"))?;
587                 state.set_pane_detached(idx, detached);
588                 needs_redraw = true;
589                 Ok(format!("{pane} detached={}", state.pane_is_detached(idx)))
590             }
591             McpAction::SetFrame { frame } => {
592                 let clamped = {
593                     let pb = state.ui_context[state.slots.playbar].inner_mut();
594                     pb.current_frame = frame.clamp(pb.start_frame, pb.end_frame).round();
595                     pb.current_frame
596                 };
597                 needs_redraw = true;
598                 Ok(format!("frame={clamped}"))
599             }
600             McpAction::CurveSetPoints { slot, points } => {
601                 if points.iter().flatten().any(|c| !c.is_finite()) {
602                     return Err("Points must be finite numbers".to_string());
603                 }
604                 let dir = state.current_dir_mut();
605                 let Some(child) = dir.children.get_mut(slot) else {
606                     return Err("Slot index out of bounds".to_string());
607                 };
608                 if !child.node_type.eq_ignore_ascii_case("curve") {
609                     return Err(format!(
610                         "Node in slot {slot} is '{}', not a curve",
611                         child.node_type
612                     ));
613                 }
614                 let pts: Vec<glam::Vec3> =
615                     points.iter().map(|p| glam::Vec3::new(p[0], p[1], p[2])).collect();
616                 let Some(p) = child.params.iter_mut().find(|p| p.name == "points") else {
617                     return Err("Curve node has no Points param".to_string());
618                 };
619                 p.set_text(crate::geometry::format_curve_points(&pts));
620                 // Same resync sequence as SetParam / the viewer state.
621                 state.sync_nodes();
622                 state.rebuild_scene_geometry();
623                 state.sync_parameters_pane();
624                 needs_redraw = true;
625                 Ok(format!("Curve points set ({})", pts.len()))
626             }
627             McpAction::ToggleCircularPane => {
628                 state.circular_network_pane = !state.circular_network_pane;
629                 let val = state.circular_network_pane;
630                 state.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
631                 state.rebuild_positions();
632                 state.apply_layout();
633                 state.sync_grid_settings();
634                 needs_redraw = true;
635                 Ok(format!("Circular pane: {}", state.circular_network_pane))
636             }
637             McpAction::MenuClick { widget_idx, menu_idx, item_idx } => {
638                 // Validate before touching menu_mut(): a non-menubar widget_idx
639                 // panics its MenuBar downcast, and out-of-range menu/item indices
640                 // used to reply "Menu clicked" while dispatching nowhere.
641                 let validated: Result<String, String> = if widget_idx >= WIDGET_COUNT {
642                     Err(format!("widget_idx {widget_idx} out of range (widget slots: 0..{WIDGET_COUNT})"))
643                 } else if state.menubar_at(widget_idx).is_some() && !menu_is_dispatched(widget_idx, menu_idx) {
644                     // Everything else a menubar lists is a registry command;
645                     // a click that was accepted here would dispatch nowhere.
646                     Err(format!(
647                         "menu {menu_idx} of menubar {widget_idx} is not dispatched by index: use run_command"
648                     ))
649                 } else if let Some(menubar) = state.menubar_at(widget_idx) {
650                     match menubar.menu_dropdowns.get(menu_idx) {
651                         None => Err(format!(
652                             "menu_idx {menu_idx} out of range: menubar {widget_idx} has {} menus",
653                             menubar.menu_dropdowns.len()
654                         )),
655                         // The reply body is interpolated into JSON unescaped, so
656                         // keep these messages free of quotes/backslashes.
657                         Some(items) => items.get(item_idx).cloned().ok_or_else(|| format!(
658                             "item_idx {item_idx} out of range: menu {menu_idx} has {} items: [{}]",
659                             items.len(), items.join(", ")
660                         )),
661                     }
662                 } else {
663                     Err(format!("widget_idx {widget_idx} is not a menubar"))
664                 };
665                 match validated {
666                     Ok(label) => {
667                         state.menu_mut(widget_idx).trigger_menu_click(menu_idx, item_idx);
668                         let _ = state.process_window_event(WindowEvent::CursorMoved { position: LocalPosition { x: -9999.0, y: -9999.0 } });
669                         needs_redraw = true;
670                         Ok(format!("Menu clicked: {label}"))
671                     }
672                     Err(e) => Err(e),
673                 }
674             }
675             McpAction::MenuAction { label } => {
676                 if state.execute_menu_action(&label) {
677                     // The arms relayout themselves but render() draws the last
678                     // uploaded buffer (same ritual as ToggleCircularPane).
679                     // The interactive menu dispatch follows its pane-show arms
680                     // with sync_nodes; execute_menu_action's copies don't, so a
681                     // spreadsheet shown here would keep stale contents without
682                     // this (the cache makes it a no-op when nothing changed).
683                     state.sync_nodes();
684                     needs_redraw = true;
685                     Ok(format!("Menu action executed: {}", label.replace(['"', '\\'], "'")))
686                 } else {
687                     Err(format!("unknown menu action label: {}", label.replace(['"', '\\'], "'")))
688                 }
689             }
690             McpAction::RunCommand { id } => {
691                 if state.run_command(&id) {
692                     state.sync_nodes();
693                     needs_redraw = true;
694                     Ok(format!("Command run: {}", id.replace(['"', '\\'], "'")))
695                 } else {
696                     Err(format!("unknown command: {}", id.replace(['"', '\\'], "'")))
697                 }
698             }
699         };
700         if needs_redraw {
701             *redraw = true;
702         }
703         state.record_structure_changes();
704         res
705     }
706 }
707 
708