graphic design tool
git clone https://git.lucas.co/cce-designer.git
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> {