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