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

commitc91ca5f937d68ae9880c18781f1920258b79cb3a
parent2d702a78c6
authorLucas Galante <[email protected]>
date2026-08-23 11:55
refactor: generate the widget roster from one list

The four dispatch matches, the struct fields and the `*_IDX` constants were four
hand-kept copies of the same 16-entry list — a new pane meant six edits, and the
only thing checking they agreed was a runtime `panic!` on the `_` arm.

`widget_roster!` takes one `INDEX_CONST: field: WidgetType` line per slot and
generates all of it: the constants numbered from declaration order, `WIDGET_COUNT`
from the length, the `WidgetSlots` fields, and `draggable`/`is_dragging`/`get_dyn`/
`get_dyn_mut`. The `_` arms funnel into `slot_out_of_range`, which returns `!` and
so fits all four signatures.

A skipped number would still compile — the last slot would sit at `WIDGET_COUNT`,
unreachable through `get_dyn` and invisible to every `0..WIDGET_COUNT` loop — so
test_widget_roster_indices_are_dense pins the expansion.

slots.rs drops 44 lines; no behavior change.

 CLAUDE.md    |  10 +--
 src/main.rs  |  19 +++++
 src/slots.rs | 225 ++++++++++++++++++++++++-----------------------------------
 3 files changed, 116 insertions(+), 138 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2370e95..2fa3655 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -80,10 +80,12 @@ engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its
   `WidgetSlots` addressed by `*_IDX` constants (`VIEWPORT_IDX`, `PARAM_IDX`,
   `NETWORK_PANEL_IDX`, … up to `WIDGET_COUNT`) rather than a dynamic tree; every slot
   is statically typed, and index-driven paths (draw order, focus cycling, broadcast
-  loops) go through `get_dyn`/`get_dyn_mut`. The typed accessors that assert a slot's
-  concrete type (`viewport()`, `graph_mut()`, `menu(idx)`, …) live here too — `State`
-  keeps one-line forwarders. Adding a slot means the constant, the field and the four
-  dispatch arms, all in this file. `PassivePlate` and `Canvas`, the two app-owned
+  loops) go through `get_dyn`/`get_dyn_mut`. The roster is declared once, as one line
+  per slot in the `widget_roster!` macro invocation (`INDEX_CONST: field: WidgetType`),
+  which generates the constants, `WIDGET_COUNT`, the struct fields and all four
+  dispatch matches — adding a pane is that one line. The typed accessors that assert a
+  slot's concrete type (`viewport()`, `graph_mut()`, `menu(idx)`, …) live here too, and
+  `State` keeps one-line forwarders. `PassivePlate` and `Canvas`, the two app-owned
   slot-only widgets, are also here.
 - `src/application.rs` — the `Application` impl: translates engine hooks into
   `WindowEvent`s, detached-window CSD, HTTP-server startup, exit autosave.
diff --git a/src/main.rs b/src/main.rs
index 821fc1e..d3354a4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -68,6 +68,25 @@ mod tests {
     use crate::shortcut::{Shortcut, ShortcutManager, Action};
     use crate::geometry::{GAttribute, GVertex, Geometry, line_vertices};
 
+    /// The roster macro (`widget_roster!` in `src/slots.rs`) numbers the `*_IDX`
+    /// constants from declaration order. A mis-expansion that skipped a number would
+    /// leave the last slot addressed as `WIDGET_COUNT`, unreachable through `get_dyn`
+    /// and invisible to every `0..WIDGET_COUNT` loop — but would still compile.
+    #[test]
+    fn test_widget_roster_indices_are_dense() {
+        use crate::slots::*;
+        let roster = [
+            HEADER_IDX, CONTENT_IDX, SPLITTER1_IDX, VIEWPORT_IDX,
+            SPLITTER2_IDX, PARAM_IDX, CANVAS_IDX, LEFT_MENUBAR_IDX,
+            RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, STATUS_IDX, BREADCRUMB_IDX,
+            SPREADSHEET_IDX, SPREADSHEET_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
+        ];
+        assert_eq!(roster.len(), WIDGET_COUNT, "roster length vs WIDGET_COUNT");
+        for (i, idx) in roster.iter().enumerate() {
+            assert_eq!(i, *idx, "slot #{i} expanded to index {idx}");
+        }
+    }
+
     #[test]
     fn test_load_default_project() {
         let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
diff --git a/src/slots.rs b/src/slots.rs
index fc49b04..c8b22ed 100644
--- a/src/slots.rs
+++ b/src/slots.rs
@@ -3,9 +3,10 @@
 //! The designer does not build a dynamic widget tree — every top-level pane, bar and
 //! plate lives in a named field of [`WidgetSlots`], and the `*_IDX` constants address
 //! those same fields positionally for the genuinely index-driven paths (draw order,
-//! focus cycling, broadcast loops). Split out of `app.rs` so that adding or reordering
-//! a slot is a change to one file: the constant, the field, and the four dispatch
-//! arms are all here, as are the typed accessors that assert each slot's concrete type.
+//! focus cycling, broadcast loops). The whole roster is declared once, in the
+//! `widget_roster!` invocation below — one line per slot, from which the constants,
+//! the struct fields and every index→field dispatch are generated. The typed accessors
+//! that assert each slot's concrete type are hand-written, below the macro.
 
 use cce_ui::widget::{
     Adapted, Breadcrumb, Graph, MenuBar, ParametersBg, Splitter, Spreadsheet, StatusBar,
@@ -15,143 +16,99 @@ use cce_ui::widget::{
 use crate::playbar::Playbar;
 use crate::viewport_3d::Viewport3D;
 
-pub const HEADER_IDX: usize = 0;
-pub const CONTENT_IDX: usize = 1;
-pub const SPLITTER1_IDX: usize = 2;
-pub const VIEWPORT_IDX: usize = 3;
-pub const SPLITTER2_IDX: usize = 4;
-pub const PARAM_IDX: usize = 5;
-pub const CANVAS_IDX: usize = 6;
-pub const LEFT_MENUBAR_IDX: usize = 7;
-pub const RIGHT_MENUBAR_IDX: usize = 8;
-pub const PARAM_MENUBAR_IDX: usize = 9;
-pub const STATUS_IDX: usize = 10;
-pub const BREADCRUMB_IDX: usize = 11;
-pub const SPREADSHEET_IDX: usize = 12;
-pub const SPREADSHEET_MENUBAR_IDX: usize = 13;
-pub const NETWORK_PANEL_IDX: usize = 14;
-pub const PLAYBAR_IDX: usize = 15;
-
-pub const WIDGET_COUNT: usize = 16;
-
-/// The roster, concretely typed (Phase 6bb): every slot's type is statically known — the
-/// old `Vec<Box<dyn WidgetHost>>` erased that and pinned `WidgetHost`'s full surface through the
-/// broadcast loops. Boxed as a whole so registered widget pointers stay stable while the
-/// containing `State` moves. The `*_IDX` constants keep addressing the same slots through
-/// `get_dyn`/`get_dyn_mut` for the genuinely index-driven paths (draw order, focus cycling,
-/// broadcast loops); everything else reaches the concrete field.
-pub struct WidgetSlots {
-    pub header: Adapted<MenuBar>,
-    pub content: Adapted<Graph>,
-    pub splitter1: Adapted<Splitter>,
-    pub viewport: Adapted<Viewport3D>,
-    pub splitter2: Adapted<Splitter>,
-    pub param: Adapted<ParametersBg>,
-    pub canvas: Adapted<Canvas>,
-    pub left_menubar: Adapted<MenuBar>,
-    pub right_menubar: Adapted<MenuBar>,
-    pub param_menubar: Adapted<MenuBar>,
-    pub status: Adapted<StatusBar>,
-    pub breadcrumb: Adapted<Breadcrumb>,
-    pub spreadsheet: Adapted<Spreadsheet>,
-    pub spreadsheet_menubar: Adapted<MenuBar>,
-    pub network_panel: Adapted<PassivePlate>,
-    pub playbar: Adapted<Playbar>,
-}
-
-impl WidgetSlots {
-
-    // Per-slot drag queries (the ControlPanel endgame took `draggable`/`is_dragging`
-    // off `WidgetHost`): the roster routes an index to the concrete slot's inherent
-    // `Adapted` read, like the other value drains.
-    pub fn draggable(&self, idx: usize) -> bool {
-        match idx {
-            HEADER_IDX => self.header.draggable(),
-            CONTENT_IDX => self.content.draggable(),
-            SPLITTER1_IDX => self.splitter1.draggable(),
-            VIEWPORT_IDX => self.viewport.draggable(),
-            SPLITTER2_IDX => self.splitter2.draggable(),
-            PARAM_IDX => self.param.draggable(),
-            CANVAS_IDX => self.canvas.draggable(),
-            LEFT_MENUBAR_IDX => self.left_menubar.draggable(),
-            RIGHT_MENUBAR_IDX => self.right_menubar.draggable(),
-            PARAM_MENUBAR_IDX => self.param_menubar.draggable(),
-            STATUS_IDX => self.status.draggable(),
-            BREADCRUMB_IDX => self.breadcrumb.draggable(),
-            SPREADSHEET_IDX => self.spreadsheet.draggable(),
-            SPREADSHEET_MENUBAR_IDX => self.spreadsheet_menubar.draggable(),
-            NETWORK_PANEL_IDX => self.network_panel.draggable(),
-            PLAYBAR_IDX => self.playbar.draggable(),
-            _ => panic!("widget slot index out of range: {idx}"),
+/// Declares the whole roster from one line per slot: `INDEX_CONST: field: WidgetType`.
+///
+/// Declaration order *is* slot order: the `*_IDX` constants are numbered from it and
+/// `WIDGET_COUNT` falls out of the length. The single list below generates the
+/// constants, the `WidgetSlots` fields, and all four index→field dispatch matches, so
+/// adding or reordering a pane is one line instead of six hand-kept edits whose only
+/// backstop was a runtime panic.
+macro_rules! widget_roster {
+    ($($idx:ident : $field:ident : $ty:ty),+ $(,)?) => {
+        widget_roster!(@number 0usize; $($idx)+);
+
+        /// The roster, concretely typed (Phase 6bb): every slot's type is statically known — the
+        /// old `Vec<Box<dyn WidgetHost>>` erased that and pinned `WidgetHost`'s full surface through the
+        /// broadcast loops. Boxed as a whole so registered widget pointers stay stable while the
+        /// containing `State` moves. The `*_IDX` constants keep addressing the same slots through
+        /// `get_dyn`/`get_dyn_mut` for the genuinely index-driven paths (draw order, focus cycling,
+        /// broadcast loops); everything else reaches the concrete field.
+        pub struct WidgetSlots {
+            $(pub $field: Adapted<$ty>,)+
         }
-    }
 
-    pub fn is_dragging(&self, idx: usize) -> bool {
-        match idx {
-            HEADER_IDX => self.header.is_dragging(),
-            CONTENT_IDX => self.content.is_dragging(),
-            SPLITTER1_IDX => self.splitter1.is_dragging(),
-            VIEWPORT_IDX => self.viewport.is_dragging(),
-            SPLITTER2_IDX => self.splitter2.is_dragging(),
-            PARAM_IDX => self.param.is_dragging(),
-            CANVAS_IDX => self.canvas.is_dragging(),
-            LEFT_MENUBAR_IDX => self.left_menubar.is_dragging(),
-            RIGHT_MENUBAR_IDX => self.right_menubar.is_dragging(),
-            PARAM_MENUBAR_IDX => self.param_menubar.is_dragging(),
-            STATUS_IDX => self.status.is_dragging(),
-            BREADCRUMB_IDX => self.breadcrumb.is_dragging(),
-            SPREADSHEET_IDX => self.spreadsheet.is_dragging(),
-            SPREADSHEET_MENUBAR_IDX => self.spreadsheet_menubar.is_dragging(),
-            NETWORK_PANEL_IDX => self.network_panel.is_dragging(),
-            PLAYBAR_IDX => self.playbar.is_dragging(),
-            _ => panic!("widget slot index out of range: {idx}"),
+        impl WidgetSlots {
+            // Per-slot drag queries (the ControlPanel endgame took `draggable`/`is_dragging`
+            // off `WidgetHost`): the roster routes an index to the concrete slot's inherent
+            // `Adapted` read, like the other value drains.
+            pub fn draggable(&self, idx: usize) -> bool {
+                match idx {
+                    $($idx => self.$field.draggable(),)+
+                    _ => slot_out_of_range(idx),
+                }
+            }
+
+            pub fn is_dragging(&self, idx: usize) -> bool {
+                match idx {
+                    $($idx => self.$field.is_dragging(),)+
+                    _ => slot_out_of_range(idx),
+                }
+            }
+
+            pub fn get_dyn(&self, idx: usize) -> &(dyn WidgetHost + 'static) {
+                match idx {
+                    $($idx => &self.$field,)+
+                    _ => slot_out_of_range(idx),
+                }
+            }
+
+            pub fn get_dyn_mut(&mut self, idx: usize) -> &mut (dyn WidgetHost + 'static) {
+                match idx {
+                    $($idx => &mut self.$field,)+
+                    _ => slot_out_of_range(idx),
+                }
+            }
         }
-    }
+    };
+
+    // Number the constants in declaration order; what is left over at the end is the count.
+    (@number $n:expr;) => {
+        pub const WIDGET_COUNT: usize = $n;
+    };
+    (@number $n:expr; $head:ident $($rest:ident)*) => {
+        pub const $head: usize = $n;
+        widget_roster!(@number $n + 1; $($rest)*);
+    };
+}
 
-    pub fn get_dyn(&self, idx: usize) -> &(dyn WidgetHost + 'static) {
-        match idx {
-            HEADER_IDX => &self.header,
-            CONTENT_IDX => &self.content,
-            SPLITTER1_IDX => &self.splitter1,
-            VIEWPORT_IDX => &self.viewport,
-            SPLITTER2_IDX => &self.splitter2,
-            PARAM_IDX => &self.param,
-            CANVAS_IDX => &self.canvas,
-            LEFT_MENUBAR_IDX => &self.left_menubar,
-            RIGHT_MENUBAR_IDX => &self.right_menubar,
-            PARAM_MENUBAR_IDX => &self.param_menubar,
-            STATUS_IDX => &self.status,
-            BREADCRUMB_IDX => &self.breadcrumb,
-            SPREADSHEET_IDX => &self.spreadsheet,
-            SPREADSHEET_MENUBAR_IDX => &self.spreadsheet_menubar,
-            NETWORK_PANEL_IDX => &self.network_panel,
-            PLAYBAR_IDX => &self.playbar,
-            _ => panic!("widget slot index out of range: {idx}"),
-        }
-    }
+/// Every dispatch match needs a `_` arm — the compiler cannot see that the constant
+/// patterns cover `0..WIDGET_COUNT` — and `!` fits all four return types.
+#[cold]
+#[inline(never)]
+fn slot_out_of_range(idx: usize) -> ! {
+    panic!("widget slot index out of range: {idx}")
+}
 
-    pub fn get_dyn_mut(&mut self, idx: usize) -> &mut (dyn WidgetHost + 'static) {
-        match idx {
-            HEADER_IDX => &mut self.header,
-            CONTENT_IDX => &mut self.content,
-            SPLITTER1_IDX => &mut self.splitter1,
-            VIEWPORT_IDX => &mut self.viewport,
-            SPLITTER2_IDX => &mut self.splitter2,
-            PARAM_IDX => &mut self.param,
-            CANVAS_IDX => &mut self.canvas,
-            LEFT_MENUBAR_IDX => &mut self.left_menubar,
-            RIGHT_MENUBAR_IDX => &mut self.right_menubar,
-            PARAM_MENUBAR_IDX => &mut self.param_menubar,
-            STATUS_IDX => &mut self.status,
-            BREADCRUMB_IDX => &mut self.breadcrumb,
-            SPREADSHEET_IDX => &mut self.spreadsheet,
-            SPREADSHEET_MENUBAR_IDX => &mut self.spreadsheet_menubar,
-            NETWORK_PANEL_IDX => &mut self.network_panel,
-            PLAYBAR_IDX => &mut self.playbar,
-            _ => panic!("widget slot index out of range: {idx}"),
-        }
-    }
+widget_roster! {
+    HEADER_IDX:              header:              MenuBar,
+    CONTENT_IDX:             content:             Graph,
+    SPLITTER1_IDX:           splitter1:           Splitter,
+    VIEWPORT_IDX:            viewport:            Viewport3D,
+    SPLITTER2_IDX:           splitter2:           Splitter,
+    PARAM_IDX:               param:               ParametersBg,
+    CANVAS_IDX:              canvas:              Canvas,
+    LEFT_MENUBAR_IDX:        left_menubar:        MenuBar,
+    RIGHT_MENUBAR_IDX:       right_menubar:       MenuBar,
+    PARAM_MENUBAR_IDX:       param_menubar:       MenuBar,
+    STATUS_IDX:              status:              StatusBar,
+    BREADCRUMB_IDX:          breadcrumb:          Breadcrumb,
+    SPREADSHEET_IDX:         spreadsheet:         Spreadsheet,
+    SPREADSHEET_MENUBAR_IDX: spreadsheet_menubar: MenuBar,
+    NETWORK_PANEL_IDX:       network_panel:       PassivePlate,
+    PLAYBAR_IDX:             playbar:             Playbar,
+}
 
+impl WidgetSlots {
     /// Roster index of the slot at `target_addr` (a thin widget address — the comparison
     /// never dereferences; callers pass `ptr as *const ()`).
     pub fn find_index(&self, target_addr: *const ()) -> Option<usize> {