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

commitb4ac763b01de1c4ed5d5b34275252a39da910319
parent4b78aa504b
authorLucas Galante <[email protected]>
date2026-07-13 14:34
fix(api): menu_click validates its target and replies honestly

The handler passed widget_idx straight into menu_mut() — a non-menubar
index panicked the MenuBar downcast (remote crash from a curl one-liner)
— and out-of-range menu/item indices replied "Menu clicked" while
dispatching nowhere (the no-op that produced the collapsed-spreadsheet
misdiagnosis). Indices are now validated against the slot roster and the
target menubar's actual dropdowns: failures name the valid range (and
list the menu's items), successes echo the clicked label so callers can
see what they actually hit. Messages stay quote-free because the reply
body is interpolated into JSON unescaped.

Live-verified: item_idx 7 on the View menu now errors listing its 5 real
items; widget_idx 6 and 99 error instead of panicking; a valid click
replies "Menu clicked: Zoom In".

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fb2AGNKjfQZxfbxP43fVbC

 src/window.rs | 39 ++++++++++++++++++++++++++++++++++-----
 1 file changed, 34 insertions(+), 5 deletions(-)

diff --git a/src/window.rs b/src/window.rs
index b4113c3..d4627e8 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -27,7 +27,7 @@ use wayland_client::{
 
 use cce_ui::widget::WidgetHost;
 use crate::shortcut::Action;
-use crate::app::{State, CustomEvent, HttpAction, ModifiersState, TouchPhase, LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, CONTENT_IDX, BREADCRUMB_IDX, VIEWPORT_IDX, PARAM_IDX, SPREADSHEET_IDX, get_next_visible_pane, Project, ProjectViewState, ParamDef, param_display};
+use crate::app::{State, CustomEvent, HttpAction, ModifiersState, TouchPhase, LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, CONTENT_IDX, BREADCRUMB_IDX, VIEWPORT_IDX, PARAM_IDX, SPREADSHEET_IDX, WIDGET_COUNT, get_next_visible_pane, Project, ProjectViewState, ParamDef, param_display};
 
 #[derive(Debug, Clone, Copy)]
 pub struct LocalPosition {
@@ -1540,10 +1540,39 @@ impl AppState {
                             Ok(format!("Circular pane: {}", state.circular_network_pane))
                         }
                         HttpAction::MenuClick { widget_idx, menu_idx, item_idx } => {
-                            state.menu_mut(widget_idx).trigger_menu_click(menu_idx, item_idx);
-                            self.process_event(WindowEvent::CursorMoved { position: LocalPosition { x: -9999.0, y: -9999.0 } });
-                            needs_redraw = true;
-                            Ok("Menu clicked".to_string())
+                            // Validate before touching menu_mut(): a non-menubar widget_idx
+                            // panics its MenuBar downcast, and out-of-range menu/item indices
+                            // used to reply "Menu clicked" while dispatching nowhere. NB the
+                            // pane-toggle items ("Show Spreadsheet Pane", ...) are NOT in these
+                            // menubars — they are button params in the menu pane, drained by
+                            // sync_parameters_to_project's label match, unreachable from here.
+                            let validated: Result<String, String> = if widget_idx >= WIDGET_COUNT {
+                                Err(format!("widget_idx {widget_idx} out of range (widget slots: 0..{WIDGET_COUNT})"))
+                            } else if let Some(menubar) = state.menubar_at(widget_idx) {
+                                match menubar.menu_dropdowns.get(menu_idx) {
+                                    None => Err(format!(
+                                        "menu_idx {menu_idx} out of range: menubar {widget_idx} has {} menus",
+                                        menubar.menu_dropdowns.len()
+                                    )),
+                                    // The reply body is interpolated into JSON unescaped, so
+                                    // keep these messages free of quotes/backslashes.
+                                    Some(items) => items.get(item_idx).cloned().ok_or_else(|| format!(
+                                        "item_idx {item_idx} out of range: menu {menu_idx} has {} items: [{}]",
+                                        items.len(), items.join(", ")
+                                    )),
+                                }
+                            } else {
+                                Err(format!("widget_idx {widget_idx} is not a menubar"))
+                            };
+                            match validated {
+                                Ok(label) => {
+                                    state.menu_mut(widget_idx).trigger_menu_click(menu_idx, item_idx);
+                                    self.process_event(WindowEvent::CursorMoved { position: LocalPosition { x: -9999.0, y: -9999.0 } });
+                                    needs_redraw = true;
+                                    Ok(format!("Menu clicked: {label}"))
+                                }
+                                Err(e) => Err(e),
+                            }
                         }
                         HttpAction::MenuClosed { widget_idx, menu_idx } => {
                             if state.active_menu_cloud_idx == Some((widget_idx, menu_idx)) {