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

commitfca706e795adbbb1e9dc653f4be056ea6419565f
parent27baed5c66
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 12:18
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 —