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

commit67fcca8995be7732b7a2746ebbf8cf98ef0a421f
parent983f249923
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 19:40
feat(menus): a plate's rows are a Plate page of its pane's menu

The network editor's and the playbar's right-click menus ended with the
plate's rows appended inline. They are one Plate page row now, turning
the menu into Collapse, Detach, the tabs, Add Tab and Move To under a
band back to it. Add Tab off that page comes back to the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md         | 22 +++++++++++++---------
 src/app.rs        | 50 ++++++++++++++++++++++++--------------------------
 src/main.rs       | 53 +++++++++++++++++++++++++++++++++++++----------------
 src/menu_page.rs  | 20 ++++++++++++++------
 src/plate_menu.rs | 38 ++++++++++++++++++++++++++++++--------
 5 files changed, 118 insertions(+), 65 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7f372a1..0f5e167 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -254,15 +254,19 @@ gone from cce-ui with the wgpu path).
   circle on each plate's top-right, which opened these rows as a menu of their
   own and, DRAGGED, moved the pane to another dock — the drag, its drop
   highlight and `AppDrag::DockDrag` went with it, and Move To is the rows'
-  replacement). The rows are in each plate's RIGHT-CLICK menu: appended under
-  the network editor's empty-space menu (`NetworkMenuAction::Plate`) and the
-  playbar's (`PlaybarMenuAction::Plate`), and the whole menu where a pane has
-  none of its own — the params pane off a row, the spreadsheet, the second
-  network editor (`open_plate_menu`, at the pointer). A row from any of them runs
-  through `run_plate_menu_action`. Add Tab is a PAGE row (see "Page rows"
-  below): the menu turns into the list of panes where it stood, under a back
-  band to whichever menu it turned from — the network's, the playbar's, or the
-  plate menu alone (`State::plate_page_from`). Collapse shrinks a plate to its title stub via
+  replacement). The rows are in each plate's RIGHT-CLICK menu: a **Plate**
+  PAGE row (see "Page rows" below) at the foot of the network editor's
+  empty-space menu (`NetworkMenuAction::PlatePage`) and the playbar's
+  (`PlaybarMenuAction::PlatePage`), which turns the menu into them under a
+  band back to it (`open_plate_page`; until 2026-10-06 they were appended
+  inline there), and the whole menu where a pane has none of its own — the
+  params pane off a row, the spreadsheet, the second network editor
+  (`open_plate_menu`, at the pointer). A row from any of them runs through
+  `run_plate_menu_action`. Add Tab is a page row too: the menu turns into
+  the list of panes where it stood, under a back band to whichever menu it
+  turned from (`State::plate_page_from`) — from a Plate page, back to that
+  page, whose band goes back to the network's or the playbar's menu
+  (`State::plate_page_root`). Collapse shrinks a plate to its title stub via
   `apply_collapsed_panes`, a post-pass over `positions[..]` (one place, all three
   branches); a LEFT press on a collapsed stub expands it, and a right press on any
   stub (collapsed or detached) opens its plate menu. `a_plates_rows_are_in_its_right_click_menu`
diff --git a/src/app.rs b/src/app.rs
index 305056e..146ac90 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -544,8 +544,9 @@ pub enum PlaybarMenuAction {
     StartFrameSlider,
     /// The frame range's far end, 2–1000 by one; kept above the near end.
     EndFrameSlider,
-    /// A row of the playbar's plate menu (Collapse, Detach).
-    Plate(crate::plate_menu::PlateMenuAction),
+    /// The Plate row: a page turning the menu into the playbar's plate rows
+    /// (Collapse, Detach).
+    PlatePage,
     /// A "-" row: engraved, inert.
     Separator,
 }
@@ -558,9 +559,9 @@ pub enum PlaybarMenuAction {
 pub enum NetworkMenuAction {
     /// Run `command::by_id(id)` — the row's label came from the same row.
     Command(&'static str),
-    /// A row of the network pane's plate menu: collapse, detach, its dock's
-    /// tabs, Move To.
-    Plate(crate::plate_menu::PlateMenuAction),
+    /// The Plate row: a page turning the menu into the network pane's
+    /// plate rows — collapse, detach, its dock's tabs, Move To.
+    PlatePage,
     /// A "-" row: engraved, inert.
     Separator,
 }
@@ -2963,6 +2964,10 @@ pub struct State {
     pub dialog_trail: Vec<crate::dialog::Mode>,
     /// The menu the plate menu's Add Tab page was turned to from.
     pub plate_page_from: Option<crate::menu_page::MenuOrigin>,
+    /// The plate PAGE shown from another menu's Plate row: the plate's slot
+    /// and that menu. Add Tab turned to from the page goes back to the page,
+    /// and the page back to the menu (`open_plate_page`).
+    pub plate_page_root: Option<(usize, crate::menu_page::MenuOrigin)>,
     /// A parameter row's right-click menu — the same thread-local; the
     /// target is (node id, parameter name) rather than a slot and a row, so
     /// it holds across a re-layout of the pane.
@@ -6598,11 +6603,10 @@ impl State {
         row(&mut options, &mut actions, "Playback Rate", PlaybarMenuAction::FpsSlider);
         row(&mut options, &mut actions, "Start Frame", PlaybarMenuAction::StartFrameSlider);
         row(&mut options, &mut actions, "End Frame", PlaybarMenuAction::EndFrameSlider);
-        let (plate, plate_actions) = self.plate_menu_rows(PLAYBAR_IDX);
-        if !plate.is_empty() {
+        // The playbar's plate rows, as a page: one Plate row.
+        if !self.plate_menu_rows(PLAYBAR_IDX).0.is_empty() {
             row(&mut options, &mut actions, "-", PlaybarMenuAction::Separator);
-            options.extend(plate);
-            actions.extend(plate_actions.into_iter().map(PlaybarMenuAction::Plate));
+            row(&mut options, &mut actions, "Plate", PlaybarMenuAction::PlatePage);
         }
         (options, actions)
     }
@@ -6651,7 +6655,7 @@ impl State {
         let (options, actions) = self.playbar_menu_rows();
         let target = self.slots.get_dyn(PLAYBAR_IDX).base().id();
         self.put_up_menu(at, None, options, 0, target);
-        crate::menu_page::mark_page_rows(&actions, |a| a == PlaybarMenuAction::Plate(crate::plate_menu::PlateMenuAction::AddTabMenu));
+        crate::menu_page::mark_page_rows(&actions, |a| a == PlaybarMenuAction::PlatePage);
         for (i, a) in actions.iter().enumerate() {
             if let Some(slider) = self.playbar_menu_slider(*a) {
                 cce_ui::widget::context_menu::set_row_slider(i, slider);
@@ -6691,12 +6695,9 @@ impl State {
         if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
             let idx = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
             let picked = idx.and_then(|i| self.playbar_menu_actions.get(i).copied());
-            let at = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
             self.close_playbar_menu();
-            match picked {
-                Some(PlaybarMenuAction::Plate(a)) => self.run_plate_menu_action(PLAYBAR_IDX, a, at),
-                Some(action) => self.run_playbar_menu_action(action),
-                None => {}
+            if let Some(action) = picked {
+                self.run_playbar_menu_action(action);
             }
             return true;
         }
@@ -6709,7 +6710,6 @@ impl State {
             PlaybarMenuAction::Command(id) => {
                 self.run_command(id);
             }
-            PlaybarMenuAction::Plate(a) => self.run_plate_menu_action(PLAYBAR_IDX, a, (self.cursor_x, self.cursor_y)),
             _ => {}
         }
     }
@@ -7375,13 +7375,13 @@ impl State {
             options.pop();
             actions.pop();
         }
-        // The network pane's plate rows, below the graph's own.
-        let (plate, plate_actions) = self.plate_menu_rows(NETWORK_PANEL_IDX);
-        if !plate.is_empty() {
+        // The network pane's plate rows, below the graph's own, as a page:
+        // one Plate row the menu turns into them (`open_plate_page`).
+        if !self.plate_menu_rows(NETWORK_PANEL_IDX).0.is_empty() {
             options.push("-".to_string());
             actions.push(NetworkMenuAction::Separator);
-            options.extend(plate);
-            actions.extend(plate_actions.into_iter().map(NetworkMenuAction::Plate));
+            options.push("Plate".to_string());
+            actions.push(NetworkMenuAction::PlatePage);
         }
 
         let target = self.slots.get_dyn(CONTENT_IDX).base().id();
@@ -7412,16 +7412,13 @@ impl State {
         if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
             let idx = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
             let picked = idx.and_then(|i| self.network_menu_actions.get(i).copied());
-            // Where the menu stands, read before it is hidden. The rows
-            // that turn the menu (Add Node, Add Tab) were taken by the
-            // press's page turn ahead of this.
-            let corner = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
+            // The rows that turn the menu (Add Node, Plate) were taken by
+            // the press's page turn ahead of this.
             self.close_network_menu();
             match picked {
                 Some(NetworkMenuAction::Command(id)) => {
                     self.run_command(id);
                 }
-                Some(NetworkMenuAction::Plate(a)) => self.run_plate_menu_action(NETWORK_PANEL_IDX, a, corner),
                 _ => {}
             }
             return true;
@@ -8542,6 +8539,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             dialog_from: None,
             dialog_trail: Vec::new(),
             plate_page_from: None,
+            plate_page_root: None,
             param_menu_active: false,
             param_menu_actions: Vec::new(),
             playbar_menu_active: false,
diff --git a/src/main.rs b/src/main.rs
index 36c0c44..ea19a5c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -404,7 +404,7 @@ mod tests {
     fn a_plates_rows_are_in_its_right_click_menu() {
         use crate::plate_menu::PlateMenuAction;
         use crate::slots::{NETWORK_PANEL_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX};
-        use cce_ui::widget::MouseButton;
+        use cce_ui::widget::{context_menu, MouseButton};
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
         state.show_spreadsheet = true;
@@ -442,29 +442,53 @@ mod tests {
         press_at(&mut state, 2.0, 2.0, MouseButton::Left);
         state.params_plate = false;
 
-        // The network: its own rows, then the plate's.
+        // The network: its own rows, then one Plate row, a page: the menu
+        // turns into the plate's rows, under a band back to it.
         let (cx, cy, cw, ch) = state.positions[crate::slots::CONTENT_IDX];
         let (px, py) = (cx + cw * 0.85, cy + ch * 0.2);
         assert!(state.graph().node_at(px, py).is_none());
         press_at(&mut state, px, py, MouseButton::Right);
         let options = cce_ui::widget::context_menu::options();
         assert_eq!(options.first().map(String::as_str), Some("Add Node"));
-        assert!(options.contains(&collapse), "{options:?}");
+        assert!(!options.contains(&collapse) && !options.iter().any(|o| o.starts_with("Move To")), "no plate rows inline: {options:?}");
+        let plate = options.iter().position(|o| o == "Plate").expect("a Plate row");
+        assert!(context_menu::leads_to_page(plate), "a page row");
+        let corner = (context_menu::x(), context_menu::y());
+        press_at(&mut state, corner.0 + 8.0, context_menu::row_y(plate) + 4.0, MouseButton::Left);
+        assert_eq!(state.plate_menu_slot, Some(NETWORK_PANEL_IDX), "turned into the plate's rows");
+        assert_eq!((context_menu::x(), context_menu::y()), corner, "where the menu stood");
+        assert_eq!(context_menu::back_title().as_deref(), Some("Network"));
+        let options = context_menu::options();
         assert!(options.iter().any(|o| o.starts_with("Move To")), "{options:?}");
-        // Picking Collapse there collapses the network plate.
-        let row = options.iter().position(|o| *o == collapse).unwrap();
-        let rx = cce_ui::widget::context_menu::x() + 8.0;
-        let ry = cce_ui::widget::context_menu::row_y(row) + 4.0;
-        press_at(&mut state, rx, ry, MouseButton::Left);
+        // Add Tab is a page off the page, and comes back to it; the page's
+        // band goes back to the network's menu.
+        if let Some(add) = state.plate_menu_actions.iter().position(|a| *a == PlateMenuAction::AddTabMenu) {
+            press_at(&mut state, corner.0 + 8.0, context_menu::row_y(add) + 4.0, MouseButton::Left);
+            assert_eq!(context_menu::back_title().as_deref(), Some(crate::plate_menu::plate_title(NETWORK_PANEL_IDX)));
+            press_at(&mut state, corner.0 + 20.0, corner.1 + context_menu::PAD + context_menu::ROW_H * 0.5, MouseButton::Left);
+            assert_eq!(context_menu::back_title().as_deref(), Some("Network"), "back on the plate page");
+        }
+        press_at(&mut state, corner.0 + 20.0, corner.1 + context_menu::PAD + context_menu::ROW_H * 0.5, MouseButton::Left);
+        assert!(state.network_menu_active && state.plate_menu_slot.is_none(), "back on the network's menu");
+        // Picking Collapse on the page collapses the network plate.
+        let plate = context_menu::options().iter().position(|o| o == "Plate").unwrap();
+        press_at(&mut state, corner.0 + 8.0, context_menu::row_y(plate) + 4.0, MouseButton::Left);
+        let row = context_menu::options().iter().position(|o| *o == collapse).unwrap();
+        press_at(&mut state, corner.0 + 8.0, context_menu::row_y(row) + 4.0, MouseButton::Left);
         assert!(state.pane_is_collapsed(NETWORK_PANEL_IDX));
         state.set_pane_collapsed(NETWORK_PANEL_IDX, false);
 
-        // The playbar: its transport, then the plate's.
+        // The playbar: its transport, then the Plate page row.
         let (x, y, w, h) = state.positions[PLAYBAR_IDX];
         press_at(&mut state, x + w * 0.5, y + h * 0.5, MouseButton::Right);
-        let options = cce_ui::widget::context_menu::options();
-        assert!(options.contains(&collapse), "{options:?}");
-        assert!(state.playbar_menu_actions.contains(&crate::app::PlaybarMenuAction::Plate(PlateMenuAction::Collapse)));
+        let options = context_menu::options();
+        assert!(!options.contains(&collapse), "{options:?}");
+        assert!(state.playbar_menu_actions.contains(&crate::app::PlaybarMenuAction::PlatePage));
+        let plate = options.iter().position(|o| o == "Plate").unwrap();
+        press_at(&mut state, context_menu::x() + 8.0, context_menu::row_y(plate) + 4.0, MouseButton::Left);
+        assert_eq!(state.plate_menu_slot, Some(PLAYBAR_IDX));
+        assert!(state.plate_menu_actions.contains(&PlateMenuAction::Collapse));
+        assert_eq!(context_menu::back_title().as_deref(), Some("Playbar"));
         press_at(&mut state, 2.0, 2.0, MouseButton::Left);
     }
 
@@ -3048,10 +3072,7 @@ mod tests {
                 vec![A::Command("play_pause"), A::Command("play_pause_reverse"), A::Command("frame_start")],
                 vec![A::Command("toggle_playbar_repeat"), A::Command("toggle_playbar_step_buttons")],
                 vec![A::FpsSlider, A::StartFrameSlider, A::EndFrameSlider],
-                vec![
-                    A::Plate(crate::plate_menu::PlateMenuAction::Collapse),
-                    A::Plate(crate::plate_menu::PlateMenuAction::Detach),
-                ],
+                vec![A::PlatePage],
             ]
         );
         let repeat = actions.iter().position(|a| *a == A::Command("toggle_playbar_repeat")).unwrap();
diff --git a/src/menu_page.rs b/src/menu_page.rs
index b12487a..d80a6c6 100644
--- a/src/menu_page.rs
+++ b/src/menu_page.rs
@@ -74,7 +74,7 @@ impl ViewportMenuAction {
 impl NetworkMenuAction {
     /// Add Node turns the menu into the add-node list; Add Tab into its page.
     pub fn leads_to_page(self) -> bool {
-        matches!(self, NetworkMenuAction::Command("add_node") | NetworkMenuAction::Plate(PlateMenuAction::AddTabMenu))
+        matches!(self, NetworkMenuAction::Command("add_node") | NetworkMenuAction::PlatePage)
     }
 }
 
@@ -150,7 +150,11 @@ impl State {
             MenuOrigin::Viewport => self.show_viewport_menu_page(None, at),
             MenuOrigin::Network => self.open_network_context_menu_at(at),
             MenuOrigin::Playbar => self.open_playbar_context_menu_at(at),
-            MenuOrigin::Plate(idx) => self.open_plate_menu_at(idx, at),
+            // A plate PAGE of another menu goes back to being that page.
+            MenuOrigin::Plate(idx) => match self.plate_page_root.filter(|(i, _)| *i == idx) {
+                Some((_, root)) => self.open_plate_page(idx, (x, y), root),
+                None => self.open_plate_menu_at(idx, at),
+            },
             MenuOrigin::Node(slot) => self.open_node_context_menu_at(slot, at),
         }
     }
@@ -187,6 +191,10 @@ impl State {
                 }
                 MenuOrigin::Plate(_) if self.plate_page_from.is_some() => {
                     let from = self.plate_page_from.take().unwrap();
+                    // Back out of a plate page to its menu: the page is done.
+                    if !matches!(from, MenuOrigin::Plate(_)) {
+                        self.plate_page_root = None;
+                    }
                     self.close_plate_menu();
                     self.reopen_menu(from, at.0, at.1);
                 }
@@ -202,16 +210,16 @@ impl State {
                         self.close_network_menu();
                         self.open_dialog_from(Mode::AddNode, origin, at);
                     }
-                    Some(NetworkMenuAction::Plate(PlateMenuAction::AddTabMenu)) => {
+                    Some(NetworkMenuAction::PlatePage) => {
                         self.close_network_menu();
-                        self.open_plate_add_tab_menu(NETWORK_PANEL_IDX, at, origin);
+                        self.open_plate_page(NETWORK_PANEL_IDX, at, origin);
                     }
                     _ => return false,
                 },
                 MenuOrigin::Playbar => match self.playbar_menu_actions.get(n).copied() {
-                    Some(PlaybarMenuAction::Plate(PlateMenuAction::AddTabMenu)) => {
+                    Some(PlaybarMenuAction::PlatePage) => {
                         self.close_playbar_menu();
-                        self.open_plate_add_tab_menu(PLAYBAR_IDX, at, origin);
+                        self.open_plate_page(PLAYBAR_IDX, at, origin);
                     }
                     _ => return false,
                 },
diff --git a/src/plate_menu.rs b/src/plate_menu.rs
index aedcdaf..c9f7e6b 100644
--- a/src/plate_menu.rs
+++ b/src/plate_menu.rs
@@ -4,16 +4,18 @@
 //!
 //! Until 2026-10-01 these rows were a menu of their own, opened by a small
 //! circular trigger on the top-right of every plate (and that trigger, dragged,
-//! moved the pane to another dock). The trigger is gone: the rows are appended
-//! to the plate's own context menu where it has one (the network editor's,
-//! the playbar's) and make up the whole menu where it has none (the params
-//! pane off a row, the spreadsheet, the second network editor, and a
-//! collapsed or detached plate's stub). Moving a pane to another dock is a
-//! row too, `Move To …`, which swaps it with what is there as the drag did.
+//! moved the pane to another dock). The trigger is gone: where the plate's
+//! pane has a context menu of its own (the network editor's, the
+//! playbar's) the rows are a PAGE of it, its **Plate** row turning the menu
+//! into them (since 2026-10-06; they were appended inline until then), and
+//! they make up the whole menu where it has none (the params pane off a
+//! row, the spreadsheet, the second network editor, and a collapsed or
+//! detached plate's stub). Moving a pane to another dock is a row too,
+//! `Move To …`, which swaps it with what is there as the drag did.
 //!
 //! [`State::plate_menu_rows`] is the one list; [`State::open_plate_menu_at`]
-//! shows it alone, and a pane with a menu of its own appends it, dispatching
-//! a pick through [`State::run_plate_menu_action`].
+//! shows it alone and [`State::open_plate_page`] as another menu's page,
+//! dispatching a pick through [`State::run_plate_menu_action`].
 
 use crate::app::{Dock, State};
 use crate::slots::{
@@ -261,6 +263,8 @@ impl State {
         if options.is_empty() {
             return;
         }
+        // A menu of its own, not another menu's page.
+        self.plate_page_root = None;
         let target = self.slots.get_dyn(idx).base().id();
         self.put_up_menu(at, None, options, 0, target);
         crate::menu_page::mark_page_rows(&actions, |a| a == PlateMenuAction::AddTabMenu);
@@ -304,6 +308,24 @@ impl State {
         self.plate_page_from = Some(from);
     }
 
+    /// `idx`'s plate rows as a PAGE of another menu — the network's or the
+    /// playbar's Plate row turned into them — at `at`, under a back band to
+    /// `from`. Add Tab turned to from here comes back here, and the band
+    /// here goes back to `from` (`State::plate_page_root`).
+    pub fn open_plate_page(&mut self, idx: usize, at: (f32, f32), from: crate::menu_page::MenuOrigin) {
+        let (options, actions) = self.plate_menu_rows(idx);
+        if options.is_empty() {
+            return;
+        }
+        let target = self.slots.get_dyn(idx).base().id();
+        self.put_up_menu(Some(at), Some(from), options, 0, target);
+        crate::menu_page::mark_page_rows(&actions, |a| a == PlateMenuAction::AddTabMenu);
+        self.plate_menu_slot = Some(idx);
+        self.plate_menu_actions = actions;
+        self.plate_page_from = Some(from);
+        self.plate_page_root = Some((idx, from));
+    }
+
     pub fn plate_menu_open(&self) -> bool {
         cce_ui::widget::context_menu::is_visible() && self.plate_menu_slot.is_some()
     }