graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the network menu's Add Node turns the menu into the list
Picking Add Node opens the add-node plate on the menu's corner rather than
centred across the window, so the menu reads as transforming into the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 8 +++++++-
src/app.rs | 12 ++++++++++--
src/dialog.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++++-
src/main.rs | 36 ++++++++++++++++++++++++++++++++++++
4 files changed, 100 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index da6d165..5089732 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2317,7 +2317,13 @@ the pointer. Until 2026-09-22 the empty-space press opened the **add-node
palette** outright, which left the network the one pane whose right-click was
not a context menu, and left every other graph-wide command reachable only by
chord or through the palette. **Add Node is the first row** instead, and picking
-it opens the same palette.
+it opens the same palette — ON THE MENU'S CORNER (since 2026-10-01): the menu
+transforms into the list, as the palette transforms into Group Markers, where
+it used to vanish for a plate centred across the window. `Dialog::anchor` holds
+the corner and `dialog::layout_at` places the plate there, giving up height
+(down to `ANCHORED_MIN_H`) before it moves up and pulling in from the right
+edge; every other opening clears the anchor and centres, Tab's Add Node
+included.
Rows are `NETWORK_MENU_COMMANDS` — a list of COMMAND IDS, `None` for a
separator — resolved through `command::by_id`, so a label is the registry's
diff --git a/src/app.rs b/src/app.rs
index cf371f4..514ee70 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -6286,9 +6286,17 @@ 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: Add Node
+ // opens its list on the menu's corner, so the menu turns into
+ // the list rather than vanishing for one across the window.
+ let corner = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
self.close_network_menu();
- if let Some(NetworkMenuAction::Command(id)) = picked {
- self.run_command(id);
+ match picked {
+ Some(NetworkMenuAction::Command("add_node")) => self.open_node_palette_at(corner.0, corner.1),
+ Some(NetworkMenuAction::Command(id)) => {
+ self.run_command(id);
+ }
+ _ => {}
}
return true;
}
diff --git a/src/dialog.rs b/src/dialog.rs
index 037ffca..be508bd 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -207,6 +207,30 @@ pub fn layout_in(width: f32, height: f32) -> (f32, f32, f32, f32) {
(x, y, w, h)
}
+/// The least height an anchored plate shrinks to before it moves up
+/// instead: a query line and a dozen rows.
+const ANCHORED_MIN_H: f32 = 340.0;
+
+/// The dialog's rect when it opens where something else stood — the network
+/// menu's Add Node, which turns the menu into the list. The top-left corner
+/// is the anchor's wherever the plate fits there: short of room below, it
+/// gives up height down to [`ANCHORED_MIN_H`] and only then rises, and it is
+/// pulled in from the right edge. The width is the centred plate's.
+pub fn layout_at(width: f32, height: f32, ax: f32, ay: f32) -> (f32, f32, f32, f32) {
+ let (_, _, w, full_h) = layout_in(width, height);
+ let x = ax.min(width - PAD - w).max(0.0).round();
+ let below = height - PAD - ay;
+ let (y, h) = if below >= full_h {
+ (ay, full_h)
+ } else if below >= ANCHORED_MIN_H.min(full_h) {
+ (ay, below)
+ } else {
+ let h = ANCHORED_MIN_H.min(full_h);
+ ((height - PAD - h).max(0.0), h)
+ };
+ (x, y.round(), w, h.floor())
+}
+
/// The query line, at the top of the plate.
fn query_rect(rect: Rect) -> Rect {
Rect { x: rect.x + PAD, y: rect.y + PAD, width: (rect.width - 2.0 * PAD).max(0.0), height: QUERY_H }
@@ -226,6 +250,9 @@ pub fn visible_rows(x: f32, y: f32, w: f32, h: f32) -> usize {
pub struct Dialog {
pub mode: Mode,
+ /// Where the plate's top-left corner goes, when it opened in place of
+ /// something else (see [`layout_at`]); `None` centres it.
+ pub anchor: Option<(f32, f32)>,
/// What has been typed into the filter.
pub query: String,
/// The filtered, ranked rows — rebuilt by the app whenever `query`
@@ -312,6 +339,7 @@ impl Dialog {
slider_stamp.set_scroll(false);
let mut d = Adapted::new(Dialog {
mode: Mode::Commands,
+ anchor: None,
query: String::new(),
rows: Vec::new(),
selected: 0,
@@ -1475,6 +1503,16 @@ impl State {
self.open_dialog_in(Mode::AddNode);
}
+ /// The add-node palette opened where the network menu stood, by that
+ /// menu's Add Node row: the menu TRANSFORMS into the list, as the
+ /// palette transforms into Group Markers. The two plates are one
+ /// material (`paint_menu_plate`), so the list taking the menu's corner
+ /// is what makes it read as the same plate grown, not a second one
+ /// arriving across the window.
+ pub fn open_node_palette_at(&mut self, x: f32, y: f32) {
+ self.open_dialog_anchored(Mode::AddNode, Some((x, y)));
+ }
+
/// Open the dialog to rename the node in `slot` of the current level.
/// The query line is the name: it opens holding the one the node has,
/// so a rename that changes a letter is a letter typed.
@@ -1506,10 +1544,15 @@ impl State {
}
fn open_dialog_in(&mut self, mode: Mode) {
+ self.open_dialog_anchored(mode, None);
+ }
+
+ fn open_dialog_anchored(&mut self, mode: Mode, anchor: Option<(f32, f32)>) {
// Always with an empty query: a dialog that reopens holding the last
// search has to be cleared before it can be used, which is a step
// every single time to save one occasionally.
self.slots.dialog.mode = mode;
+ self.slots.dialog.anchor = anchor;
self.slots.dialog.query.clear();
self.slots.dialog.set_visible(true);
self.refresh_dialog_rows();
@@ -2130,7 +2173,10 @@ impl State {
self.positions[DIALOG_IDX] = (0.0, 0.0, 0.0, 0.0);
return;
}
- let (x, y, w, h) = layout_in(self.width, self.height);
+ let (x, y, w, h) = match self.slots.dialog.anchor {
+ Some((ax, ay)) => layout_at(self.width, self.height, ax, ay),
+ None => layout_in(self.width, self.height),
+ };
self.positions[DIALOG_IDX] = (x, y, w, h);
self.slots.dialog.set_page(visible_rows(x, y, w, h));
}
diff --git a/src/main.rs b/src/main.rs
index 6cd8941..1ed5604 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15313,6 +15313,7 @@ mod tests {
state.handle_event(&WindowEvent::CursorMoved {
position: LocalPosition { x: rx as f64, y: ry as f64 },
});
+ let menu_corner = (cce_ui::widget::context_menu::x(), cce_ui::widget::context_menu::y());
state.handle_event(&WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
@@ -15321,6 +15322,41 @@ mod tests {
assert!(state.dialog_visible());
assert_eq!(state.slots.dialog.mode, Mode::AddNode);
assert_eq!((state.grid_cursor_col, state.grid_cursor_row), cell);
+
+ // The menu turns into the list: the plate opens on the menu's
+ // corner, as far as the window lets it, not centred.
+ let (dx, dy, dw, dh) = state.positions[crate::slots::DIALOG_IDX];
+ let want = crate::dialog::layout_at(state.width, state.height, menu_corner.0, menu_corner.1);
+ assert_eq!((dx, dy, dw, dh), want);
+ assert_ne!((dx, dy), {
+ let (x, y, _, _) = crate::dialog::layout_in(state.width, state.height);
+ (x, y)
+ }, "not the centred plate");
+ assert!(dx + dw <= state.width && dy + dh <= state.height, "inside the window");
+
+ // Tab and the palette's other openings still centre it.
+ state.close_dialog();
+ state.open_node_palette();
+ assert_eq!(
+ state.positions[crate::slots::DIALOG_IDX],
+ crate::dialog::layout_in(state.width, state.height)
+ );
+ }
+
+ /// An anchored plate keeps its corner where it fits, gives up height
+ /// before it moves, and rises only below the least height it keeps.
+ #[test]
+ fn an_anchored_dialog_keeps_the_corner_it_was_given() {
+ use crate::dialog::{layout_at, layout_in};
+ let (_, _, w, h) = layout_in(1600.0, 1200.0);
+ assert_eq!(layout_at(1600.0, 1200.0, 100.0, 50.0), (100.0, 50.0, w, h), "room for all of it");
+ let (x, y, _, short) = layout_at(1600.0, 1200.0, 100.0, 700.0);
+ assert_eq!((x, y), (100.0, 700.0), "the corner stays");
+ assert!(short < h && short >= 340.0, "it is shorter: {short}");
+ let (_, y, _, hh) = layout_at(1600.0, 1200.0, 100.0, 1100.0);
+ assert!(y < 1100.0 && y + hh <= 1200.0, "too low, it rises: {y} + {hh}");
+ let (x, _, _, _) = layout_at(1600.0, 1200.0, 1500.0, 50.0);
+ assert!(x + w <= 1600.0, "pulled in from the right: {x}");
}
/// Every row of the network context menu names a command that exists —