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 ¶ms_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 ¶ms_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 ¶ms_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