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

src/window.rs (51.2K)

   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::shortcut::Action;
  13 use crate::app::{State, CustomEvent, McpAction, get_next_visible_pane, Project, ParamDef};
  14 use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, PARAM_IDX, WIDGET_COUNT};
  15 
  16 #[derive(Debug, Clone, Copy)]
  17 pub struct LocalPosition {
  18     pub x: f64,
  19     pub y: f64,
  20 }
  21 
  22 pub enum WindowEvent {
  23     MouseWheel { delta: cce_ui::widget::MouseScrollDelta },
  24     CursorMoved { position: LocalPosition },
  25     MouseInput { state: cce_ui::widget::ElementState, button: cce_ui::widget::MouseButton },
  26     KeyboardInput { event: cce_ui::widget::KeyEvent },
  27 }
  28 
  29 impl State {
  30     /// Route a window event through `handle_event`, then run the post-event
  31     /// side-effect pass (menu clicks, pane toggles, pending actions).
  32     /// Returns true when a redraw is needed.
  33     pub(crate) fn process_window_event(&mut self, ev: WindowEvent) -> bool {
  34         let mut result = false;
  35         {
  36             let state = &mut *self;
  37             let mut changed = state.handle_event(&ev);
  38 
  39             if let Some(seg) = state.path_mut().path_click() {
  40                 if seg < state.current_path.len() {
  41                     let exited_idx = state.current_path.get(seg).copied();
  42                     state.current_path.truncate(seg);
  43                     state.on_path_changed();
  44                     if let Some(idx) = exited_idx {
  45                         let pos = {
  46                             let dir = state.current_dir();
  47                             if idx < dir.children.len() {
  48                                 Some(dir.children[idx].position)
  49                             } else {
  50                                 None
  51                             }
  52                         };
  53                         if let Some((pos_x, pos_y)) = pos {
  54                             state.grid_cursor_col = pos_x as i32;
  55                             state.grid_cursor_row = pos_y as i32;
  56                             state.sync_cursor_and_selection();
  57                         }
  58                     }
  59                     changed = true;
  60                 }
  61             }
  62 
  63             // The second network editor's breadcrumb navigates ITS path.
  64             {
  65                 use cce_ui::widget::PathController as _;
  66                 if let Some(seg) = state.slots.breadcrumb2.path_click() {
  67                     if seg < state.current_path2.len() {
  68                         state.current_path2.truncate(seg);
  69                         state.sync_nodes();
  70                         // The viewport tracks its editor's level.
  71                         if state.viewport_editor() == crate::slots::CONTENT2_IDX {
  72                             state.rebuild_scene_geometry();
  73                         }
  74                         changed = true;
  75                     }
  76                 }
  77             }
  78 
  79             if let Some(id) = state.pending_command.take() {
  80                 state.run_command(id);
  81                 changed = true;
  82             }
  83 
  84 
  85             // Check context switcher dropdown changes
  86             for &widget_idx in &[HEADER_IDX, LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX] {
  87                 if let Some(new_sel) = state.menu_mut(widget_idx).take_context_change() {
  88                     let target_pane = match new_sel {
  89                         0 => LEFT_MENUBAR_IDX,
  90                         1 => RIGHT_MENUBAR_IDX,
  91                         2 => PARAM_MENUBAR_IDX,
  92                         3 => SPREADSHEET_MENUBAR_IDX,
  93                         4 => HEADER_IDX,
  94                         _ => continue,
  95                     };
  96                     if state.focused_pane != target_pane {
  97                         state.focused_pane = target_pane;
  98                         // Make sure the switched pane is visible!
  99                         match target_pane {
 100                             LEFT_MENUBAR_IDX => {
 101                                 if !state.show_network {
 102                                     state.show_network = true;
 103                                     state.slots.content.set_visible(true);
 104                                     state.slots.left_menubar.set_visible(true);
 105                                     state.slots.breadcrumb.set_visible(true);
 106                                     state.menu_mut(HEADER_IDX).set_item_checked(2, 4, true);
 107                                 }
 108                             }
 109                             RIGHT_MENUBAR_IDX => {
 110                                 if !state.show_viewport {
 111                                     state.show_viewport = true;
 112                                     state.slots.viewport.set_visible(true);
 113                                     state.slots.right_menubar.set_visible(true);
 114                                     state.menu_mut(HEADER_IDX).set_item_checked(2, 5, true);
 115                                 }
 116                             }
 117                             PARAM_MENUBAR_IDX => {
 118                                 if !state.show_parameters {
 119                                     state.show_parameters = true;
 120                                     state.slots.param.set_visible(true);
 121                                     state.slots.param_menubar.set_visible(true);
 122                                     state.menu_mut(HEADER_IDX).set_item_checked(2, 6, true);
 123                                 }
 124                             }
 125                             SPREADSHEET_MENUBAR_IDX => {
 126                                 if !state.show_spreadsheet {
 127                                     state.show_spreadsheet = true;
 128                                     state.slots.spreadsheet.set_visible(true);
 129                                     state.slots.spreadsheet_menubar.set_visible(true);
 130                                     state.menu_mut(HEADER_IDX).set_item_checked(2, 7, true);
 131                                 }
 132                             }
 133                             _ => {}
 134                         }
 135                         state.rebuild_positions();
 136                         state.apply_layout();
 137                         state.sync_pane_focus();
 138                         state.sync_nodes();
 139                         changed = true;
 140                     }
 141                 }
 142             }
 143 
 144             if let Some((menu_idx, item_idx)) = state.menu_mut(HEADER_IDX).menu_click() {
 145                 if menu_idx == 0 { // File
 146                     match item_idx {
 147                         0 => { // New Project
 148                             state.new_project();
 149                             changed = true;
 150                         }
 151                         1 => { // Open
 152                             state.open_file_chooser();
 153                             changed = true;
 154                         }
 155                         2 => { // Save
 156                             let path_opt = state.loaded_project_path.clone();
 157                             if let Some(path) = path_opt {
 158                                 if let Err(e) = state.save_to_file(&path) {
 159                                     eprintln!("Failed to save project: {:?}", e);
 160                                     state.update_status_text(&format!("Failed to save: {:?}", e));
 161                                 } else {
 162                                     state.update_status_text(&format!("Project saved to {}", path.display()));
 163                                     state.add_recent_file(path);
 164                                 }
 165                             } else {
 166                                 state.save_file_chooser();
 167                             }
 168                             changed = true;
 169                         }
 170                         3 => { // Save As
 171                             state.save_file_chooser();
 172                             changed = true;
 173                         }
 174                         4 => { // Exit
 175                             state.exit_requested = true;
 176                         }
 177                         _ => {}
 178                     }
 179                 } else if menu_idx == 2 { // View
 180                     match item_idx {
 181                         0 => { // Zoom In
 182                             state.zoom(1.15, None);
 183                             changed = true;
 184                         }
 185                         1 => { // Zoom Out
 186                             state.zoom(1.0 / 1.15, None);
 187                             changed = true;
 188                         }
 189                         2 => { // Reset Zoom
 190                             state.set_grid_geometry(crate::app::configured_grid_geometry());
 191                             state.sync_grid_settings();
 192                             changed = true;
 193                         }
 194                         3 => { // Detach Circular Window
 195                             state.execute_action(Action::DetachCircularWindow);
 196                             changed = true;
 197                         }
 198                         4 => { // Show Network Pane
 199                             state.show_network = !state.show_network;
 200                             state.slots.content.set_visible(state.show_network);
 201                             state.slots.left_menubar.set_visible(state.show_network);
 202                             state.slots.breadcrumb.set_visible(state.show_network);
 203                             let val = state.show_network;
 204                             state.menu_mut(HEADER_IDX).set_item_checked(2, 4, val);
 205                             if !state.show_network && state.focused_pane == LEFT_MENUBAR_IDX {
 206                                 state.focused_pane = get_next_visible_pane(
 207                                     state.focused_pane,
 208                                     state.show_network,
 209                                     state.show_viewport,
 210                                     state.show_parameters,
 211                                     state.show_spreadsheet,
 212                                     false,
 213                                 );
 214                             }
 215                             state.rebuild_positions();
 216                             state.apply_layout();
 217                             state.sync_pane_focus();
 218                             state.sync_nodes();
 219                             changed = true;
 220                         }
 221                         5 => { // Show Viewport Pane
 222                             state.show_viewport = !state.show_viewport;
 223                             state.slots.viewport.set_visible(state.show_viewport);
 224                             state.slots.right_menubar.set_visible(state.show_viewport);
 225                             let val = state.show_viewport;
 226                             state.menu_mut(HEADER_IDX).set_item_checked(2, 5, val);
 227                             if !state.show_viewport && state.focused_pane == RIGHT_MENUBAR_IDX {
 228                                 state.focused_pane = get_next_visible_pane(
 229                                     state.focused_pane,
 230                                     state.show_network,
 231                                     state.show_viewport,
 232                                     state.show_parameters,
 233                                     state.show_spreadsheet,
 234                                     false,
 235                                 );
 236                             }
 237                             state.rebuild_positions();
 238                             state.apply_layout();
 239                             state.sync_pane_focus();
 240                             state.sync_nodes();
 241                             changed = true;
 242                         }
 243                         6 => { // Show Parameters Pane
 244                             state.show_parameters = !state.show_parameters;
 245                             state.slots.param.set_visible(state.show_parameters);
 246                             state.slots.param_menubar.set_visible(state.show_parameters);
 247                             let val = state.show_parameters;
 248                             state.menu_mut(HEADER_IDX).set_item_checked(2, 6, val);
 249                             if !state.show_parameters && state.focused_pane == PARAM_MENUBAR_IDX {
 250                                 state.focused_pane = get_next_visible_pane(
 251                                     state.focused_pane,
 252                                     state.show_network,
 253                                     state.show_viewport,
 254                                     state.show_parameters,
 255                                     state.show_spreadsheet,
 256                                     false,
 257                                 );
 258                             }
 259                             state.rebuild_positions();
 260                             state.apply_layout();
 261                             state.sync_pane_focus();
 262                             state.sync_nodes();
 263                             changed = true;
 264                         }
 265                         7 => { // Show Spreadsheet Pane
 266                             state.show_spreadsheet = !state.show_spreadsheet;
 267                             state.slots.spreadsheet.set_visible(state.show_spreadsheet);
 268                             state.slots.spreadsheet_menubar.set_visible(state.show_spreadsheet);
 269                             let val = state.show_spreadsheet;
 270                             state.menu_mut(HEADER_IDX).set_item_checked(2, 7, val);
 271                             if !state.show_spreadsheet && state.focused_pane == SPREADSHEET_MENUBAR_IDX {
 272                                 state.focused_pane = get_next_visible_pane(
 273                                     state.focused_pane,
 274                                     state.show_network,
 275                                     state.show_viewport,
 276                                     state.show_parameters,
 277                                     state.show_spreadsheet,
 278                                     false,
 279                                 );
 280                             }
 281                             state.rebuild_positions();
 282                             state.apply_layout();
 283                             state.sync_pane_focus();
 284                             state.sync_nodes();
 285                             changed = true;
 286                         }
 287                         8 => { // Show Playbar Pane
 288                             state.execute_menu_action("Show Playbar Pane");
 289                             state.sync_nodes();
 290                             changed = true;
 291                         }
 292                         _ => {}
 293                     }
 294                 }
 295             }
 296 
 297             if let Some((menu_idx, item_idx)) = state.menu_mut(LEFT_MENUBAR_IDX).menu_click() {
 298                 if menu_idx == 0 { // File
 299                     match item_idx {
 300                         0 => { // New
 301                             state.new_project();
 302                             changed = true;
 303                         }
 304                         1 => { // Open
 305                             state.open_file_chooser();
 306                             changed = true;
 307                         }
 308                         2 => { // Save
 309                             let path_opt = state.loaded_project_path.clone();
 310                             if let Some(path) = path_opt {
 311                                 if let Err(e) = state.save_to_file(&path) {
 312                                     eprintln!("Failed to save project: {:?}", e);
 313                                     state.update_status_text(&format!("Failed to save: {:?}", e));
 314                                 } else {
 315                                     state.update_status_text(&format!("Project saved to {}", path.display()));
 316                                     state.add_recent_file(path);
 317                                 }
 318                             } else {
 319                                 state.save_file_chooser();
 320                             }
 321                             changed = true;
 322                         }
 323                         3 => { // Save As
 324                             state.save_file_chooser();
 325                             changed = true;
 326                         }
 327                         _ => {}
 328                     }
 329                 } else if menu_idx == 2 { // View
 330                     match item_idx {
 331                         0 => { // Zoom In
 332                             state.zoom(1.15, None);
 333                             changed = true;
 334                         }
 335                         1 => { // Zoom Out
 336                             state.zoom(1.0 / 1.15, None);
 337                             changed = true;
 338                         }
 339                         2 => {
 340                             state.circular_network_pane = !state.circular_network_pane;
 341                             let val = state.circular_network_pane;
 342                             state.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
 343                             state.rebuild_positions();
 344                             state.apply_layout();
 345                             state.sync_grid_settings();
 346                             changed = true;
 347                         }
 348                         3 => { // Detach Pane
 349                             state.execute_action(Action::DetachCircularWindow);
 350                             changed = true;
 351                         }
 352                         4 => { // Close Pane
 353                             state.show_network = false;
 354                             state.slots.content.set_visible(false);
 355                             state.slots.left_menubar.set_visible(false);
 356                             state.slots.breadcrumb.set_visible(false);
 357                             state.menu_mut(HEADER_IDX).set_item_checked(2, 4, false);
 358                             if state.focused_pane == LEFT_MENUBAR_IDX {
 359                                 state.focused_pane = get_next_visible_pane(
 360                                     state.focused_pane,
 361                                     state.show_network,
 362                                     state.show_viewport,
 363                                     state.show_parameters,
 364                                     state.show_spreadsheet,
 365                                     false,
 366                                 );
 367                             }
 368                             state.rebuild_positions();
 369                             state.apply_layout();
 370                             state.sync_pane_focus();
 371                             state.sync_nodes();
 372                             changed = true;
 373                         }
 374                         _ => {}
 375                     }
 376                 }
 377             }
 378 
 379             if let Some((menu_idx, item_idx)) = state.menu_mut(RIGHT_MENUBAR_IDX).menu_click() {
 380                 if menu_idx == 0 {
 381                     let camera_nodes: Vec<String> = state.current_dir().children.iter()
 382                         .filter(|c| c.node_type == "camera")
 383                         .map(|c| c.name.clone())
 384                         .collect();
 385                     let mut items = vec!["Default Camera".to_string()];
 386                     items.extend(camera_nodes);
 387                     if item_idx < items.len() {
 388                         state.set_active_camera(items[item_idx].clone());
 389                         let active_cam = state.active_camera.clone();
 390                         for (i, item) in items.iter().enumerate() {
 391                             state.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(0, i, item == &active_cam);
 392                         }
 393                         changed = true;
 394                     }
 395                 } else if menu_idx == 3 { // View
 396                     if item_idx == 0 { // Close Pane
 397                         state.show_viewport = false;
 398                         state.slots.viewport.set_visible(false);
 399                         state.slots.right_menubar.set_visible(false);
 400                         state.menu_mut(HEADER_IDX).set_item_checked(2, 5, false);
 401                         if state.focused_pane == RIGHT_MENUBAR_IDX {
 402                             state.focused_pane = get_next_visible_pane(
 403                                 state.focused_pane,
 404                                 state.show_network,
 405                                 state.show_viewport,
 406                                 state.show_parameters,
 407                                 state.show_spreadsheet,
 408                                 false,
 409                             );
 410                         }
 411                         state.rebuild_positions();
 412                         state.apply_layout();
 413                         state.sync_pane_focus();
 414                         state.sync_nodes();
 415                         changed = true;
 416                     }
 417                 } else {
 418                     let action = if menu_idx == 1 {
 419                         Some(Action::ToggleSquareViewport)
 420                     } else if menu_idx == 2 {
 421                         match item_idx {
 422                             0 => Some(Action::ToggleGrid),
 423                             1 => Some(Action::ToggleCube),
 424                             2 => Some(Action::ToggleOrigin),
 425                             3 => Some(Action::ToggleCameraPivot),
 426                             _ => None,
 427                         }
 428                     } else { None };
 429                     if let Some(a) = action {
 430                         state.execute_action(a);
 431                         changed = true;
 432                     }
 433                 }
 434             }
 435 
 436             if let Some((menu_idx, item_idx)) = state.menu_mut(PARAM_MENUBAR_IDX).menu_click() {
 437                 if menu_idx == 0 { // Preset
 438                     if let Some(slot_idx) = state.graph().selected_node() {
 439                         let node_type = state.current_dir().children[slot_idx].node_type.clone();
 440                         let template_params = state.node_templates.iter()
 441                             .find(|t| t.node.node_type == node_type)
 442                             .map(|t| t.node.params.clone());
 443                         if let Some(params_to_reset) = template_params {
 444                             if item_idx == 0 { // Default
 445                                 for template_param in &params_to_reset {
 446                                     if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
 447                                         p.default = template_param.default.clone();
 448                                     }
 449                                 }
 450                             } else if item_idx == 1 { // Custom
 451                                 for template_param in &params_to_reset {
 452                                     if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
 453                                         if let Ok(v) = template_param.default.parse::<f32>() {
 454                                             p.default = format!("{:.2}", v * 1.5);
 455                                         } else if let Ok(v) = template_param.default.parse::<i32>() {
 456                                             p.default = format!("{}", v * 2);
 457                                         } else if template_param.default.contains(':') {
 458                                             let parts: Vec<&str> = template_param.default.split(':').collect();
 459                                             let custom_parts: Vec<String> = parts.iter().map(|p_str| {
 460                                                 if let Ok(v) = p_str.parse::<f32>() {
 461                                                     format!("{:.2}", v * 1.5)
 462                                                 } else {
 463                                                     p_str.to_string()
 464                                                 }
 465                                             }).collect();
 466                                             p.default = custom_parts.join(":");
 467                                         } else {
 468                                             p.default = template_param.default.clone();
 469                                         }
 470                                     }
 471                                 }
 472                             }
 473                             state.sync_nodes();
 474                             state.rebuild_scene_geometry();
 475                             changed = true;
 476                         }
 477                     }
 478                 } else if menu_idx == 1 { // Reset
 479                     if item_idx == 0 { // All
 480                         if let Some(slot_idx) = state.graph().selected_node() {
 481                             let node_type = state.current_dir().children[slot_idx].node_type.clone();
 482                             let template_params = state.node_templates.iter()
 483                                 .find(|t| t.node.node_type == node_type)
 484                                 .map(|t| t.node.params.clone());
 485                             if let Some(params_to_reset) = template_params {
 486                                 for template_param in &params_to_reset {
 487                                     if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
 488                                         p.default = template_param.default.clone();
 489                                     }
 490                                 }
 491                                 state.sync_nodes();
 492                                 state.rebuild_scene_geometry();
 493                                 changed = true;
 494                             }
 495                         }
 496                     }
 497                 } else if menu_idx == 2 { // View
 498                     if item_idx == 0 { // Close Pane
 499                         state.show_parameters = false;
 500                         state.slots.param.set_visible(false);
 501                         state.slots.param_menubar.set_visible(false);
 502                         state.menu_mut(HEADER_IDX).set_item_checked(2, 6, false);
 503                         if state.focused_pane == PARAM_MENUBAR_IDX {
 504                             state.focused_pane = get_next_visible_pane(
 505                                 state.focused_pane,
 506                                 state.show_network,
 507                                 state.show_viewport,
 508                                 state.show_parameters,
 509                                 state.show_spreadsheet,
 510                                 false,
 511                             );
 512                         }
 513                         state.rebuild_positions();
 514                         state.apply_layout();
 515                         state.sync_pane_focus();
 516                         state.sync_nodes();
 517                         changed = true;
 518                     }
 519                 }
 520             }
 521 
 522             if let Some((menu_idx, item_idx)) = state.menu_mut(SPREADSHEET_MENUBAR_IDX).menu_click() {
 523                 if menu_idx == 0 { // View
 524                     if item_idx == 0 { // Close Pane
 525                         state.show_spreadsheet = false;
 526                         state.slots.spreadsheet.set_visible(false);
 527                         state.slots.spreadsheet_menubar.set_visible(false);
 528                         state.menu_mut(HEADER_IDX).set_item_checked(2, 7, false);
 529                         if state.focused_pane == SPREADSHEET_MENUBAR_IDX {
 530                             state.focused_pane = get_next_visible_pane(
 531                                 state.focused_pane,
 532                                 state.show_network,
 533                                 state.show_viewport,
 534                                 state.show_parameters,
 535                                 state.show_spreadsheet,
 536                                 false,
 537                             );
 538                         }
 539                         state.rebuild_positions();
 540                         state.apply_layout();
 541                         state.sync_pane_focus();
 542                         state.sync_nodes();
 543                         changed = true;
 544                     }
 545                 }
 546             }
 547 
 548             if changed {
 549                 state.sync_layout();
 550                 state.read_panel_offsets();
 551                 state.sync_cursor_and_selection();
 552 
 553                 if state.drag_widget == Some(PARAM_IDX) && state.slots.param.is_dragging() {
 554                     state.sync_parameters_to_project();
 555                 }
 556 
 557                 state.sync_nodes();
 558 
 559                 // Sync Parameters pane with selected node — through the one
 560                 // pane-sync path, so textpick rows survive this rebuild.
 561                 state.sync_parameters_pane();
 562 
 563             }
 564 
 565             state.update_status_text(&format!(
 566                 "col: {:.0}  vp: {:.0}  params: {:.0}",
 567                 state.content_left_w(), state.viewport_w(), state.param_w(),
 568             ));
 569 
 570             if changed {
 571                 state.update_window_title();
 572                 if state.is_detached_network || state.detached_circular_network {
 573                     state.needs_autosave = true;
 574                 }
 575                 result = true;
 576             }
 577         }
 578         result
 579     }
 580 
 581     /// Apply an automation event (the engine `update` hook). Returns true
 582     /// when a redraw is needed.
 583     pub(crate) fn apply_custom_event(&mut self, event: CustomEvent) -> bool {
 584         let mut needs_redraw = false;
 585         {
 586             let state = &mut *self;
 587             match event {
 588                 CustomEvent::McpCall(call) => {
 589                     let res = state.apply_mcp_call(&call, &mut needs_redraw);
 590                     let _ = call.reply.send(res);
 591                 }
 592                 CustomEvent::RunAction(action) => {
 593                     if let Err(e) = state.apply_action(action, &mut needs_redraw) {
 594                         state.update_status_text(&e);
 595                         needs_redraw = true;
 596                     }
 597                 }
 598                 // Exit is handled by the Application::update wrapper
 599                 // (autosave + engine exit) before this is reached.
 600                 CustomEvent::Exit => {}
 601             }
 602         }
 603         if needs_redraw {
 604             self.update_window_title();
 605             if self.is_detached_network || self.detached_circular_network {
 606                 self.needs_autosave = true;
 607             }
 608         }
 609         needs_redraw
 610     }
 611 
 612     /// Snapshot the project (node tree + view state) for state queries.
 613     fn project_snapshot(&self) -> Project {
 614         Project {
 615             name: "Project".to_string(),
 616             root: self.fs_root.clone(),
 617             view_state: self.project_view_state(),
 618             format: crate::app::PROJECT_FORMAT,
 619         }
 620     }
 621 
 622     /// An MCP tool call: `get_state` returns the project snapshot; every
 623     /// other tool name is an `McpAction` tag — injected into the arguments
 624     /// and run through the shared action path.
 625     fn apply_mcp_call(
 626         &mut self,
 627         call: &cce_ui::mcp::McpToolCall,
 628         needs_redraw: &mut bool,
 629     ) -> Result<serde_json::Value, String> {
 630         if call.name == "get_state" {
 631             let mut v = serde_json::to_value(self.project_snapshot())
 632                 .map_err(|e| format!("failed to serialize state: {e}"))?;
 633             // Additive sibling of the project fields: the playbar is app
 634             // state, not project state, so it must not enter the Project
 635             // struct (the save format) — but automation needs to read it.
 636             let pb = self.slots.playbar.inner();
 637             v["playbar"] = serde_json::json!({
 638                 "frame": pb.current_frame.round() as i64,
 639                 "playing": pb.playing,
 640                 "reversed": pb.reversed,
 641                 "start_frame": pb.start_frame.round() as i64,
 642                 "end_frame": pb.end_frame.round() as i64,
 643             });
 644             // The network grid as it is right now, beside what config says
 645             // it is at 100%: the one way to check, from outside, that a
 646             // config edit reached the lattice on screen.
 647             let cfg = crate::app::configured_grid_geometry();
 648             v["grid"] = serde_json::json!({
 649                 "pitch": [self.grid_pitch_x, self.grid_pitch_y],
 650                 "node_size": [self.node_w, self.node_h],
 651                 "zoom_percent": self.zoom_percent(),
 652                 "configured_pitch": [cfg.pitch_x, cfg.pitch_y],
 653                 "configured_node_size": [cfg.node_w, cfg.node_h],
 654             });
 655             return Ok(v);
 656         }
 657         let mut req = if call.arguments.is_object() {
 658             call.arguments.clone()
 659         } else {
 660             serde_json::json!({})
 661         };
 662         req["action"] = serde_json::Value::String(call.name.clone());
 663         match serde_json::from_value::<McpAction>(req) {
 664             Ok(action) => self
 665                 .apply_action(action, needs_redraw)
 666                 .map(serde_json::Value::String),
 667             Err(e) => Err(format!("invalid arguments for '{}': {e}", call.name)),
 668         }
 669     }
 670 
 671     /// Apply one automation action (an MCP tool call, or an app-internal
 672     /// fire-and-forget `RunAction`).
 673     pub(crate) fn apply_action(
 674         &mut self,
 675         action: McpAction,
 676         redraw: &mut bool,
 677     ) -> Result<String, String> {
 678         let mut needs_redraw = false;
 679         let state = self;
 680         let res = match action {
 681             McpAction::Up => {
 682                 if state.move_up() {
 683                     // on_path_changed clears the selection but leaves the param
 684                     // pane to process_window_event's tail, which MCP bypasses.
 685                     state.sync_parameters_pane();
 686                     needs_redraw = true;
 687                     Ok("Moved up".to_string())
 688                 } else {
 689                     Err("Already at root".to_string())
 690                 }
 691             }
 692             McpAction::Enter { slot } => {
 693                 let dir = state.current_dir();
 694                 if slot < dir.children.len() && (dir.children[slot].node_type == "node" || !dir.children[slot].children.is_empty()) {
 695                     state.current_path.push(slot);
 696                     state.on_path_changed();
 697                     state.sync_parameters_pane();
 698                     needs_redraw = true;
 699                     Ok("Entered subnet".to_string())
 700                 } else {
 701                     Err("Not a valid subnet".to_string())
 702                 }
 703             }
 704             McpAction::Select { slot } => {
 705                 if slot < state.current_dir().children.len() {
 706                     // Same effect as clicking the node: it becomes the selected node and
 707                     // its params populate the parameter pane. A click also reaches
 708                     // sync_nodes via process_window_event's changed-path, which is
 709                     // what refreshes the spreadsheet — this path must call it itself.
 710                     state.graph_mut().set_selected_node(Some(slot));
 711                     state.sync_parameters_pane();
 712                     state.sync_nodes();
 713                     needs_redraw = true;
 714                     Ok("Node selected".to_string())
 715                 } else {
 716                     Err("Slot index out of bounds".to_string())
 717                 }
 718             }
 719             McpAction::SetParam { slot, name, value } => {
 720                 let dir = state.current_dir_mut();
 721                 if let Some(child) = dir.children.get_mut(slot) {
 722                     if let Some(p) = child.params.iter_mut().find(|p| p.name == name) {
 723                         p.default = value;
 724                         // A value that reads as a reference becomes an
 725                         // expression, as one typed into the pane does.
 726                         if !p.expr && p.takes_expressions() && crate::expr::looks_like_expression(&p.default) {
 727                             p.expr = true;
 728                         }
 729                         // Same sequence as the interactive param-pane
 730                         // path, so settings params (viewport flags,
 731                         // grid) actually take effect via automation.
 732                         state.sync_grid_settings();
 733                         state.sync_nodes();
 734                         state.rebuild_scene_geometry();
 735                         // Interactively the edit originates IN the param pane;
 736                         // here it must be pushed back or a selected node's pane
 737                         // keeps showing the old value.
 738                         state.sync_parameters_pane();
 739                         needs_redraw = true;
 740                         Ok("Parameter updated".to_string())
 741                     } else {
 742                         Err(format!("Parameter {} not found", name))
 743                     }
 744                 } else {
 745                     Err("Slot index out of bounds".to_string())
 746                 }
 747             }
 748             McpAction::ResetCamera => {
 749                 if state.active_camera != "Default Camera" {
 750                     state.update_active_camera_rotation_reset();
 751                 } else {
 752                     state.viewport_mut().rotation_y = 0.0;
 753                     state.viewport_mut().rotation_x = 0.0;
 754                 }
 755                 state.viewport_mut().zoom = 1.0;
 756                 state.viewport_mut().reset_velocity();
 757                 needs_redraw = true;
 758                 Ok("Camera reset".to_string())
 759             }
 760             McpAction::Load { path } => {
 761                 if let Err(e) = state.load_from_file(Path::new(&path)) {
 762                     Err(format!("Load failed: {:?}", e))
 763                 } else {
 764                     needs_redraw = true;
 765                     Ok("Project loaded".to_string())
 766                 }
 767             }
 768             McpAction::Save { path } => {
 769                 if let Err(e) = state.save_to_file(Path::new(&path)) {
 770                     Err(format!("Save failed: {:?}", e))
 771                 } else {
 772                     let path_buf = Path::new(&path).to_path_buf();
 773                     state.loaded_project_path = Some(path_buf.clone());
 774                     state.add_recent_file(path_buf);
 775                     needs_redraw = true;
 776                     Ok("Project saved".to_string())
 777                 }
 778             }
 779             McpAction::ToggleGeometry { slot } => {
 780                 let active_nodes = state.current_dir().children.len();
 781                 if slot < active_nodes {
 782                     {
 783                         let visible = !state.current_dir().children[slot].geometry_visible;
 784                         state.current_dir_mut().set_child_geometry_visible(slot, visible);
 785                         state.sync_nodes();
 786                         state.rebuild_scene_geometry();
 787                         needs_redraw = true;
 788                         Ok(format!("Geometry visible: {}", visible))
 789                     }
 790                 } else {
 791                     Err("Slot out of bounds".to_string())
 792                 }
 793             }
 794             McpAction::AddNode { template_name, name, x, y } => {
 795                 let template_idx = state.node_templates.iter().position(|t| {
 796                     t.label.to_lowercase() == template_name.to_lowercase()
 797                         || t.node.name.to_lowercase() == template_name.to_lowercase()
 798                 });
 799                 if let Some(idx) = template_idx {
 800                     let mut node = state.node_templates[idx].node.clone();
 801                     // Fresh ids, like paste: a verbatim clone shares the
 802                     // template's ids across every instance.
 803                     crate::app::regenerate_node_ids(&mut node);
 804                     {
 805                         let (nx, ny) = state.find_empty_cell(x, y, None);
 806                         node.position = (nx, ny);
 807                         if let Some(n) = name {
 808                             node.name = crate::app::sanitize_node_name(&n);
 809                         } else {
 810                             node.name = state.get_lowest_unused_name(&node.name);
 811                         }
 812                         // New nodes arrive with their display flag OFF: the
 813                         // one-visible-per-directory rule means showing is an
 814                         // explicit act ('e', the click toggle), never a side
 815                         // effect of adding. Top-level flag only — a subnet
 816                         // template's internal chain keeps its own flags.
 817                         node.geometry_visible = false;
 818                         state.current_dir_mut().children.push(node);
 819                         state.sync_nodes();
 820                         state.rebuild_positions();
 821                         state.apply_layout();
 822                         state.update_panel_bounds();
 823                         state.rebuild_scene_geometry();
 824                         needs_redraw = true;
 825                         Ok("Node added".to_string())
 826                     }
 827                 } else {
 828                     Err(format!("Template '{}' not found", template_name))
 829                 }
 830             }
 831             McpAction::DeleteNode { slot } => {
 832                 if state.delete_node(slot) {
 833                     // delete_node clears/shifts the selection; the param pane
 834                     // resync normally comes from process_window_event's tail.
 835                     state.sync_parameters_pane();
 836                     needs_redraw = true;
 837                     Ok("Node deleted".to_string())
 838                 } else {
 839                     Err("Slot out of bounds".to_string())
 840                 }
 841             }
 842             McpAction::RenameNode { slot, new_name } => {
 843                 let len = state.current_dir().children.len();
 844                 if slot < len {
 845                     let new_name = crate::app::sanitize_node_name(&new_name);
 846                     let (id, old_name) = {
 847                         let n = &state.current_dir().children[slot];
 848                         (n.id.clone(), n.name.clone())
 849                     };
 850                     // Everything that names the node follows it: the wires,
 851                     // the expressions anywhere in the tree, the active camera.
 852                     crate::geometry::rename_node_in_tree(&mut state.fs_root, &id, &new_name);
 853                     if state.active_camera == old_name {
 854                         state.active_camera = new_name.clone();
 855                     }
 856                     state.sync_nodes();
 857                     // Connections reference nodes by name (Input params), so a
 858                     // rename changes downstream evaluation.
 859                     state.rebuild_scene_geometry();
 860                     needs_redraw = true;
 861                     Ok("Node renamed".to_string())
 862                 } else {
 863                     Err("Slot out of bounds".to_string())
 864                 }
 865             }
 866             McpAction::MoveNode { slot, x, y } => {
 867                 let len = state.current_dir().children.len();
 868                 if slot < len {
 869                     let (nx, ny) = state.find_empty_cell(x, y, Some(slot));
 870                     state.current_dir_mut().children[slot].position = (nx, ny);
 871                     state.sync_nodes();
 872                     state.rebuild_positions();
 873                     state.apply_layout();
 874                     state.update_panel_bounds();
 875                     needs_redraw = true;
 876                     Ok("Node moved".to_string())
 877                 } else {
 878                     Err("Slot out of bounds".to_string())
 879                 }
 880             }
 881             McpAction::AddParam { slot, name, param_type, default } => {
 882                 let len = state.current_dir().children.len();
 883                 if slot < len {
 884                     let param = ParamDef {
 885                         name,
 886                         label: String::new(),
 887                         param_type,
 888                         default,
 889                         options: vec![],
 890                         min: None,
 891                         max: None,
 892                         step: None,
 893                         show_when: String::new(), expr: false,
 894                     };
 895                     state.current_dir_mut().children[slot].params.push(param);
 896                     state.sync_nodes();
 897                     // Params feed kernel evaluation and the param pane shows
 898                     // the selected node's list — same rationale as SetParam.
 899                     state.rebuild_scene_geometry();
 900                     state.sync_parameters_pane();
 901                     needs_redraw = true;
 902                     Ok("Parameter added".to_string())
 903                 } else {
 904                     Err("Slot out of bounds".to_string())
 905                 }
 906             }
 907             McpAction::DeleteParam { slot, name } => {
 908                 let len = state.current_dir().children.len();
 909                 if slot < len {
 910                     let params = &mut state.current_dir_mut().children[slot].params;
 911                     if let Some(pos) = params.iter().position(|p| p.name == name) {
 912                         params.remove(pos);
 913                         state.sync_nodes();
 914                         state.rebuild_scene_geometry();
 915                         state.sync_parameters_pane();
 916                         needs_redraw = true;
 917                         Ok("Parameter deleted".to_string())
 918                     } else {
 919                         Err(format!("Parameter '{}' not found", name))
 920                     }
 921                 } else {
 922                     Err("Slot out of bounds".to_string())
 923                 }
 924             }
 925             McpAction::SetPaneCollapsed { pane, collapsed } => {
 926                 let idx = crate::plate_corner::pane_slot_from_name(&pane)
 927                     .ok_or_else(|| format!("unknown pane: {pane}"))?;
 928                 state.set_pane_collapsed(idx, collapsed);
 929                 needs_redraw = true;
 930                 Ok(format!("{pane} collapsed={collapsed}"))
 931             }
 932             McpAction::SetPaneDetached { pane, detached } => {
 933                 let idx = crate::plate_corner::pane_slot_from_name(&pane)
 934                     .ok_or_else(|| format!("unknown pane: {pane}"))?;
 935                 state.set_pane_detached(idx, detached);
 936                 needs_redraw = true;
 937                 Ok(format!("{pane} detached={}", state.pane_is_detached(idx)))
 938             }
 939             McpAction::SetFrame { frame } => {
 940                 let clamped = {
 941                     let pb = state.slots.playbar.inner_mut();
 942                     pb.current_frame = frame.clamp(pb.start_frame, pb.end_frame).round();
 943                     pb.current_frame
 944                 };
 945                 needs_redraw = true;
 946                 Ok(format!("frame={clamped}"))
 947             }
 948             McpAction::CurveSetPoints { slot, points } => {
 949                 if points.iter().flatten().any(|c| !c.is_finite()) {
 950                     return Err("Points must be finite numbers".to_string());
 951                 }
 952                 let dir = state.current_dir_mut();
 953                 let Some(child) = dir.children.get_mut(slot) else {
 954                     return Err("Slot index out of bounds".to_string());
 955                 };
 956                 if !child.node_type.eq_ignore_ascii_case("curve") {
 957                     return Err(format!(
 958                         "Node in slot {slot} is '{}', not a curve",
 959                         child.node_type
 960                     ));
 961                 }
 962                 let pts: Vec<glam::Vec3> =
 963                     points.iter().map(|p| glam::Vec3::new(p[0], p[1], p[2])).collect();
 964                 let Some(p) = child.params.iter_mut().find(|p| p.name == "Points") else {
 965                     return Err("Curve node has no Points param".to_string());
 966                 };
 967                 p.default = crate::geometry::format_curve_points(&pts);
 968                 // Same resync sequence as SetParam / the viewer state.
 969                 state.sync_nodes();
 970                 state.rebuild_scene_geometry();
 971                 state.sync_parameters_pane();
 972                 needs_redraw = true;
 973                 Ok(format!("Curve points set ({})", pts.len()))
 974             }
 975             McpAction::ToggleCircularPane => {
 976                 state.circular_network_pane = !state.circular_network_pane;
 977                 let val = state.circular_network_pane;
 978                 state.menu_mut(LEFT_MENUBAR_IDX).set_item_checked(2, 2, val);
 979                 state.rebuild_positions();
 980                 state.apply_layout();
 981                 state.sync_grid_settings();
 982                 needs_redraw = true;
 983                 Ok(format!("Circular pane: {}", state.circular_network_pane))
 984             }
 985             McpAction::MenuClick { widget_idx, menu_idx, item_idx } => {
 986                 // Validate before touching menu_mut(): a non-menubar widget_idx
 987                 // panics its MenuBar downcast, and out-of-range menu/item indices
 988                 // used to reply "Menu clicked" while dispatching nowhere. NB the
 989                 // pane-toggle items ("Show Spreadsheet Pane", ...) are NOT in these
 990                 // menubars — they are toggle params in the menu pane, drained by
 991                 // sync_parameters_to_project's label match, unreachable from here.
 992                 let validated: Result<String, String> = if widget_idx >= WIDGET_COUNT {
 993                     Err(format!("widget_idx {widget_idx} out of range (widget slots: 0..{WIDGET_COUNT})"))
 994                 } else if let Some(menubar) = state.menubar_at(widget_idx) {
 995                     match menubar.menu_dropdowns.get(menu_idx) {
 996                         None => Err(format!(
 997                             "menu_idx {menu_idx} out of range: menubar {widget_idx} has {} menus",
 998                             menubar.menu_dropdowns.len()
 999                         )),
1000                         // The reply body is interpolated into JSON unescaped, so
1001                         // keep these messages free of quotes/backslashes.
1002                         Some(items) => items.get(item_idx).cloned().ok_or_else(|| format!(
1003                             "item_idx {item_idx} out of range: menu {menu_idx} has {} items: [{}]",
1004                             items.len(), items.join(", ")
1005                         )),
1006                     }
1007                 } else {
1008                     Err(format!("widget_idx {widget_idx} is not a menubar"))
1009                 };
1010                 match validated {
1011                     Ok(label) => {
1012                         state.menu_mut(widget_idx).trigger_menu_click(menu_idx, item_idx);
1013                         let _ = state.process_window_event(WindowEvent::CursorMoved { position: LocalPosition { x: -9999.0, y: -9999.0 } });
1014                         needs_redraw = true;
1015                         Ok(format!("Menu clicked: {label}"))
1016                     }
1017                     Err(e) => Err(e),
1018                 }
1019             }
1020             McpAction::MenuAction { label } => {
1021                 if state.execute_menu_action(&label) {
1022                     // The arms relayout themselves but render() draws the last
1023                     // uploaded buffer (same ritual as ToggleCircularPane).
1024                     // The interactive menu dispatch follows its pane-show arms
1025                     // with sync_nodes; execute_menu_action's copies don't, so a
1026                     // spreadsheet shown here would keep stale contents without
1027                     // this (the cache makes it a no-op when nothing changed).
1028                     state.sync_nodes();
1029                     needs_redraw = true;
1030                     Ok(format!("Menu action executed: {}", label.replace(['"', '\\'], "'")))
1031                 } else {
1032                     Err(format!("unknown menu action label: {}", label.replace(['"', '\\'], "'")))
1033                 }
1034             }
1035             McpAction::RunCommand { id } => {
1036                 if state.run_command(&id) {
1037                     state.sync_nodes();
1038                     needs_redraw = true;
1039                     Ok(format!("Command run: {}", id.replace(['"', '\\'], "'")))
1040                 } else {
1041                     Err(format!("unknown command: {}", id.replace(['"', '\\'], "'")))
1042                 }
1043             }
1044         };
1045         if needs_redraw {
1046             *redraw = true;
1047         }
1048         res
1049     }
1050 }
1051 
1052