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