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

commit28d6ecc1adc7f8191f78315535823c7cf433e377
parentb0d90541c2
authorLucas Galante <[email protected]>
date2026-09-19 00:07
feat(network): auto-layout, as a layered assignment on the existing grid

The network is already a grid — positions are integer cells and the
keyboard cursor steps cell by cell — so arranging it is not the usual
force-directed sprawl. src/layout.rs assigns layers on those cells: a
node's ROW is how far downstream it is, its COLUMN is chosen to sit under
what it reads from. A chain comes out as one vertical line, which is what
a chain already looks like in every project in the repo (a Sphere at
(4, 2) feeding an output at (4, 3)).

Edges come from the same rule the WIRES do — a node's Input parameter
naming another node, the widget's wire_pairs derivation. Matching it is
the point: a layout computed from relationships you cannot see would move
nodes for reasons that are not on screen. The cost is that a second
operand — a Boolean's With, a Copy's target — does not pull on the
layout, because it does not draw a wire either. When those become wires
they should become edges here in the same change.

Row is the LONGEST path from a root, not the shortest, so a node sits
below every one of its inputs rather than beside one of them. Depth
iterates to a fixed point instead of recursing: a name-wired graph can be
cyclic (A reads B reads A is something a user can type), and the loop
stops improving rather than overflowing the stack.

Utility trees are pinned. The settings node lives where the user put it,
and an "arrange everything" that relocated it would be a surprise every
time; its cell counts as occupied so nothing lands on top. Within a row a
node wants its parent's column — a root wants the column it already has,
which preserves the left-to-right order among independent chains — and
takes the nearest free column to that, searching outward, so a chain
stays perfectly vertical and a collision nudges one node aside instead of
shifting the whole row right.

arrange() returns only the nodes that MOVED, so the command can say
"moved 3 nodes" or "every node was already in place". An arrange that did
nothing because the layout was already right looks identical to a broken
one, and the status line is the only thing that separates them.

Ctrl+Shift+L rather than the bare L Houdini uses: bare hjkl is the
cursor, and shift+hjkl is reserved for the select family this app cannot
implement until the Graph widget has multi-selection, so taking Shift+L
now would have to be given back later.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md       |  43 +++++++++++++++
 shapeshifter.md |  27 ++++++++--
 src/app.rs      |  65 ++++++++++++++++++++++
 src/command.rs  |   4 ++
 src/layout.rs   | 131 ++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs     | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/shortcut.rs |   2 +
 7 files changed, 433 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cc6a2c2..53db2da 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -478,6 +478,49 @@ by hand: `edit_handles` from `Ctrl+H` to `Ctrl+Shift+H` (the ctrl+hjkl family
 owns those now), and `f` now frames the CURSOR where it used to frame
 everything, with framing everything on `shift+f` — the plugin's split.
 
+### Auto-layout
+
+`src/layout.rs` arranges a level's nodes from their wiring. The network is
+already a GRID — positions are integer cells and the keyboard cursor steps cell
+by cell — so this is a layered assignment on cells, not a force-directed
+sprawl: a node's ROW is how far downstream it is, its COLUMN is chosen to sit
+under what it reads from.
+
+**Edges come from the same rule the wires do** — a node's `Input` parameter
+naming another node, which is the widget's `wire_pairs` derivation. Matching it
+is the point: a layout computed from relationships you cannot see would move
+nodes for reasons that are not on screen. It also means a second operand (a
+Boolean's `With`, a Copy's target) does not pull on the layout, because it does
+not draw a wire either. When those become wires they should become edges here
+in the same change.
+
+Flow is downward, matching every project in the repo (a Sphere at (4, 2)
+feeding an output at (4, 3)). Row is the LONGEST path from a root, not the
+shortest, so a node always sits below every one of its inputs rather than
+beside one of them. Depth is computed by iterating to a fixed point rather than
+by recursion, because a name-wired graph can be cyclic — A reads B reads A is
+something a user can type — and the loop stops improving instead of
+overflowing the stack.
+
+Utility trees are pinned: the settings node lives where the user put it, and an
+"arrange everything" that relocated it would be a surprise every time. Their
+cells count as occupied so nothing lands on top of them. Within a row, a node
+wants its parent's column (a root wants the column it already has, which
+preserves the left-to-right order among independent chains) and takes the
+nearest free column to that, searching outward — so a chain stays perfectly
+vertical and a collision nudges one node aside instead of shifting the whole
+row.
+
+`arrange` returns only the nodes that MOVED, so `layout_current_level` can say
+"moved 3 nodes" or "every node was already in place" — an arrange that did
+nothing because the layout was already right looks identical to a broken one,
+and the status line is the only thing that separates them.
+
+The command is `layout_nodes` on `Ctrl+Shift+L` rather than the bare `L`
+Houdini uses: bare hjkl is the cursor, and shift+hjkl is reserved for the
+select family this app cannot implement until the Graph widget has
+multi-selection, so taking `Shift+L` now would have to be given back later.
+
 ### Commands, chords and the palette
 
 `src/command.rs` is one list of everything the app can be asked to do. Each row
diff --git a/shapeshifter.md b/shapeshifter.md
index b9538c9..72a9c90 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -410,9 +410,30 @@ Touches: `geometry.rs`, `nodes/*.json`.
 > the previous round, and the test named the winner and the shadowed command
 > rather than leaving a key that silently stopped working.
 >
-> Still outstanding for this phase: auto-layout in the network pane. The keycam
-> navigator the proposal names as a viewer state is not written yet, but the
-> framework it would sit on is.
+> **Auto-layout landed, and Phase 5's list is done.** `src/layout.rs` arranges a
+> level from its wiring: row is how far downstream a node is, column is chosen
+> to sit under what it reads from. Because the network is already a grid of
+> integer cells, this is a layered assignment rather than the usual
+> force-directed sprawl — and a chain comes out as one vertical line, which is
+> what a chain already looks like in every project in the repo.
+>
+> Edges come from the same rule the WIRES do: a node's `Input` naming another.
+> A layout computed from relationships you cannot see would move nodes for
+> reasons that are not on screen. The cost is that a second operand — a
+> Boolean's `With` — does not pull on the layout, because it does not draw a
+> wire either; those should become edges here in the same change that makes
+> them wires.
+>
+> Depth iterates to a fixed point rather than recursing, because a name-wired
+> graph can be cyclic: `A` reads `B` reads `A` is something a user can type, and
+> it must terminate rather than overflow. Utility trees are pinned, and their
+> cells count as occupied.
+>
+> The proposal's Phase 5 list is now complete: conditional parameter rows, the
+> command registry and palette, the hotkey file (which turned out to already
+> exist, better than proposed), the viewer-state framework, keyboard graph
+> navigation, and auto-layout. The keycam navigator is named there as a viewer
+> state ON that framework rather than as a list item, and is not written.
 
 Independent of all the geometry work, and the place where the app gets to be
 better rather than equal. A **command palette** on the HC Panel's model — fuzzy
diff --git a/src/app.rs b/src/app.rs
index 04cfbe9..93ba77c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -5116,6 +5116,68 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
     }
 
+    /// Arrange the current level's nodes from their wiring.
+    ///
+    /// Reports how many moved: an arrange that did nothing — because the
+    /// layout was already right — looks identical to one that is broken, and
+    /// the status line is the only thing that can tell them apart.
+    pub(crate) fn layout_current_level(&mut self) -> bool {
+        if self.focused_pane != LEFT_MENUBAR_IDX {
+            return false;
+        }
+        let nodes: Vec<crate::layout::LayoutNode> = self
+            .current_dir()
+            .children
+            .iter()
+            .map(|c| crate::layout::LayoutNode {
+                name: c.name.clone(),
+                input: c
+                    .params
+                    .iter()
+                    .find(|p| p.name.eq_ignore_ascii_case("input"))
+                    .map(|p| p.default.clone()),
+                position: c.position,
+                // Utility trees stay where they were put; see the module doc.
+                pinned: matches!(c.node_type.as_str(), "utility" | "session" | "meta"),
+            })
+            .collect();
+        let moved = crate::layout::arrange(&nodes);
+        let count = moved.len();
+        {
+            let dir = self.current_dir_mut();
+            for (idx, pos) in moved {
+                if let Some(child) = dir.children.get_mut(idx) {
+                    child.position = pos;
+                }
+            }
+        }
+        if count > 0 {
+            self.sync_nodes();
+            self.sync_layout();
+            // The cursor tracks the selection, which has just moved with its
+            // node — otherwise the next keypress navigates from a cell the
+            // selected node no longer occupies.
+            let sel_pos = self
+                .graph()
+                .selected_node()
+                .and_then(|sel| self.current_dir().children.get(sel).map(|c| c.position));
+            if let Some((cx, cy)) = sel_pos {
+                self.grid_cursor_col = cx as i32;
+                self.grid_cursor_row = cy as i32;
+            }
+            self.keep_cursor_in_view();
+            self.rebuild_positions();
+            self.apply_layout();
+            self.update_panel_bounds();
+        }
+        self.update_status_text(&match count {
+            0 => "Layout: every node was already in place.".to_string(),
+            1 => "Layout: moved 1 node.".to_string(),
+            n => format!("Layout: moved {n} nodes."),
+        });
+        true
+    }
+
     /// Move the grid cursor one cell, taking the selection with it.
     ///
     /// The cursor is the network pane's keyboard position: `sync_cursor_and_
@@ -5324,6 +5386,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             Action::NetworkPan(dc, dr) => {
                 self.network_pan_view(dc, dr);
             }
+            Action::LayoutNodes => {
+                self.layout_current_level();
+            }
             Action::FrameCursor => {
                 self.frame_cursor();
             }
diff --git a/src/command.rs b/src/command.rs
index 0f8d5dd..13c53f2 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -136,6 +136,10 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "view_up", label: "Pan View Up", context: Context::Network, run: Run::Key(Action::NetworkPan(0, -1)), default_chord: Some("Ctrl+k") },
     Command { id: "view_right", label: "Pan View Right", context: Context::Network, run: Run::Key(Action::NetworkPan(1, 0)), default_chord: Some("Ctrl+l") },
     Command { id: "frame_cursor", label: "Frame Cursor", context: Context::Network, run: Run::Key(Action::FrameCursor), default_chord: Some("f") },
+    // Ctrl+Shift+L rather than the L that Houdini uses: bare hjkl is the
+    // cursor, and shift+hjkl is reserved for the select family this app cannot
+    // implement yet — taking Shift+L now would have to be given back later.
+    Command { id: "layout_nodes", label: "Layout Nodes", context: Context::Network, run: Run::Key(Action::LayoutNodes), default_chord: Some("Ctrl+Shift+l") },
     Command { id: "frame_all", label: "Frame All", context: Context::Network, run: Run::Key(Action::FrameAll), default_chord: Some("Shift+f") },
 
     // --- Network ---
diff --git a/src/layout.rs b/src/layout.rs
new file mode 100644
index 0000000..b705fbe
--- /dev/null
+++ b/src/layout.rs
@@ -0,0 +1,131 @@
+//! Auto-layout: arrange a level's nodes from their wiring.
+//!
+//! The network is already a GRID — every node's position is an integer cell,
+//! and the keyboard cursor moves cell by cell — so this is not the usual
+//! force-directed sprawl. It is a layered assignment on cells: a node's ROW is
+//! how far it is downstream, and its COLUMN is chosen to sit under the node it
+//! reads from.
+//!
+//! **Edges come from the same rule the wires do**: a node's `Input` parameter
+//! naming another node. That is the widget's `wire_pairs` derivation, and
+//! matching it is the point — a layout computed from relationships you cannot
+//! see would move nodes for reasons that are not on screen. It also means a
+//! second operand (a Boolean's `With`, a Copy's target) does not pull on the
+//! layout, because it does not draw a wire either. When those become wires,
+//! they should become edges here in the same change.
+//!
+//! **Flow is downward**, matching every project in the repo: a Sphere at
+//! (4, 2) feeds an output at (4, 3). Row is the LONGEST path from a root, not
+//! the shortest, so a node always sits below every one of its inputs rather
+//! than beside one of them.
+//!
+//! Utility nodes are pinned. The settings tree lives at a place the user put
+//! it, and an "arrange everything" that relocated the meta node would be a
+//! surprise every time. Their cells are treated as occupied so nothing lands
+//! on top of them.
+
+/// One node's layout input: what it is called, what it reads, where it is now,
+/// and whether it may be moved.
+pub struct LayoutNode {
+    pub name: String,
+    /// The value of its `Input` parameter, if it has one.
+    pub input: Option<String>,
+    pub position: (f32, f32),
+    pub pinned: bool,
+}
+
+/// New positions for the nodes that moved, as (index, (column, row)).
+///
+/// Only movers are returned, so a caller can tell whether the layout changed
+/// anything and report it — an arrange that silently did nothing looks broken.
+pub fn arrange(nodes: &[LayoutNode]) -> Vec<(usize, (f32, f32))> {
+    let n = nodes.len();
+    if n == 0 {
+        return Vec::new();
+    }
+
+    // Parent index per node, by the wires' own rule.
+    let parent: Vec<Option<usize>> = nodes
+        .iter()
+        .map(|node| {
+            let want = node.input.as_deref()?.trim();
+            if want.is_empty() {
+                return None;
+            }
+            nodes.iter().position(|other| other.name == want)
+        })
+        .collect();
+
+    // Depth by longest path, iteratively. A name-wired graph can contain a
+    // cycle (A reads B reads A), and the fixed point below simply stops
+    // improving instead of recursing forever — the cycle's members end up at
+    // the deepest row any of them could justify, which is as meaningful an
+    // answer as a cyclic graph has.
+    let mut depth = vec![0usize; n];
+    for _ in 0..n {
+        let mut changed = false;
+        for i in 0..n {
+            if let Some(p) = parent[i] {
+                if p != i && depth[p] + 1 > depth[i] {
+                    depth[i] = depth[p] + 1;
+                    changed = true;
+                }
+            }
+        }
+        if !changed {
+            break;
+        }
+    }
+
+    // Cells a pinned node holds; the assignment steps around them.
+    let mut taken: Vec<(i32, i32)> = nodes
+        .iter()
+        .filter(|node| node.pinned)
+        .map(|node| (node.position.0 as i32, node.position.1 as i32))
+        .collect();
+
+    let max_depth = (0..n).filter(|&i| !nodes[i].pinned).map(|i| depth[i]).max().unwrap_or(0);
+    let mut column = vec![0i32; n];
+    let mut placed = vec![false; n];
+    let mut out = Vec::new();
+
+    for row in 0..=max_depth {
+        let mut in_row: Vec<usize> =
+            (0..n).filter(|&i| !nodes[i].pinned && depth[i] == row).collect();
+
+        // Order within the row by where the node WANTS to be, so the ordering
+        // and the placement agree and the pass does not fight itself. A root's
+        // wish is its current column, which preserves the left-to-right
+        // arrangement the user already made among independent chains.
+        let wish = |i: usize, column: &Vec<i32>, placed: &Vec<bool>| -> i32 {
+            match parent[i] {
+                Some(p) if placed[p] => column[p],
+                _ => nodes[i].position.0.round() as i32,
+            }
+        };
+        in_row.sort_by_key(|&i| (wish(i, &column, &placed), nodes[i].name.clone()));
+
+        for i in in_row {
+            let want = wish(i, &column, &placed);
+            // Nearest free column to the one it wants, searching outward so a
+            // collision nudges a node aside rather than pushing the whole row
+            // to the right. A chain whose parent's column is free stays
+            // perfectly vertical, which is what a chain should look like.
+            // Terminates because `taken` is finite: some column is always free.
+            let col = (0i32..)
+                .flat_map(|step| {
+                    if step == 0 { vec![want] } else { vec![want + step, want - step] }
+                })
+                .find(|c| !taken.contains(&(*c, row as i32)))
+                .expect("an unbounded column scan always finds a free cell");
+            taken.push((col, row as i32));
+            column[i] = col;
+            placed[i] = true;
+            let new_pos = (col as f32, row as f32);
+            if new_pos != nodes[i].position {
+                out.push((i, new_pos));
+            }
+        }
+    }
+    out
+}
diff --git a/src/main.rs b/src/main.rs
index 4db2956..fec396f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,6 +26,7 @@ pub mod render;
 pub mod shortcut;
 pub mod slots;
 pub mod command;
+pub mod layout;
 pub mod page;
 pub mod thumbnail;
 
@@ -4387,6 +4388,169 @@ mod tests {
         }
     }
 
+    /// Auto-layout: rows are how far downstream a node is, columns keep it
+    /// under what it reads from.
+    #[test]
+    fn test_auto_layout_lays_a_chain_out_vertically() {
+        use crate::layout::{arrange, LayoutNode};
+        let node = |name: &str, input: Option<&str>, pos: (f32, f32)| LayoutNode {
+            name: name.to_string(),
+            input: input.map(|s| s.to_string()),
+            position: pos,
+            pinned: false,
+        };
+
+        // A chain, scattered. It should come back as one vertical line,
+        // because a chain IS a vertical line in this grid — a sphere at
+        // (4, 2) feeding an output at (4, 3) is the convention every project
+        // in the repo already uses.
+        let nodes = vec![
+            node("c", Some("b"), (7.0, 0.0)),
+            node("a", None, (2.0, 5.0)),
+            node("b", Some("a"), (0.0, 9.0)),
+        ];
+        let moved: std::collections::HashMap<usize, (f32, f32)> =
+            arrange(&nodes).into_iter().collect();
+        let at = |i: usize| moved.get(&i).copied().unwrap_or(nodes[i].position);
+        assert_eq!(at(1).1, 0.0, "the root is not on the top row");
+        assert_eq!(at(2).1, 1.0, "its child is not one row below it");
+        assert_eq!(at(0).1, 2.0, "the grandchild is not two rows below");
+        assert_eq!(at(1).0, at(2).0, "a chain should be one column");
+        assert_eq!(at(2).0, at(0).0, "a chain should be one column");
+
+        // A root's existing column is its wish, so two independent chains keep
+        // the left-to-right order the user gave them.
+        let nodes = vec![
+            node("right", None, (5.0, 0.0)),
+            node("left", None, (1.0, 0.0)),
+            node("right_child", Some("right"), (0.0, 0.0)),
+            node("left_child", Some("left"), (0.0, 0.0)),
+        ];
+        let moved: std::collections::HashMap<usize, (f32, f32)> =
+            arrange(&nodes).into_iter().collect();
+        let at = |i: usize| moved.get(&i).copied().unwrap_or(nodes[i].position);
+        assert!(at(1).0 < at(0).0, "left should stay left of right");
+        assert_eq!(at(1).0, at(3).0, "left's child should sit under it");
+        assert_eq!(at(0).0, at(2).0, "right's child should sit under it");
+        assert_eq!(at(2).1, 1.0);
+        assert_eq!(at(3).1, 1.0);
+    }
+
+    /// The cases that would otherwise hang or overwrite: cycles, self
+    /// reference, dangling names, and pinned cells.
+    #[test]
+    fn test_auto_layout_survives_cycles_and_pinned_nodes() {
+        use crate::layout::{arrange, LayoutNode};
+        let node = |name: &str, input: Option<&str>, pos: (f32, f32), pinned: bool| LayoutNode {
+            name: name.to_string(),
+            input: input.map(|s| s.to_string()),
+            position: pos,
+            pinned,
+        };
+
+        // A name-wired graph can be cyclic; it must terminate rather than
+        // recurse, and the answer only has to be finite and sane.
+        let cyclic = vec![
+            node("a", Some("b"), (0.0, 0.0), false),
+            node("b", Some("a"), (1.0, 0.0), false),
+            node("self", Some("self"), (2.0, 0.0), false),
+        ];
+        let moved = arrange(&cyclic);
+        assert!(moved.len() <= 3);
+        for (_, (c, r)) in &moved {
+            assert!(c.is_finite() && r.is_finite() && *r >= 0.0);
+        }
+
+        // A dangling input name is simply no edge — the node is a root, not an
+        // error and not a crash.
+        let dangling = vec![node("a", Some("nothing_called_this"), (3.0, 4.0), false)];
+        let moved: Vec<_> = arrange(&dangling);
+        assert_eq!(moved, vec![(0, (3.0, 0.0))], "a dangling input should make a root");
+
+        // Pinned nodes never move, and nothing is placed on top of them.
+        let pinned = vec![
+            node("meta", None, (0.0, 0.0), true),
+            node("a", None, (0.0, 5.0), false),
+            node("b", Some("a"), (0.0, 6.0), false),
+        ];
+        let moved: std::collections::HashMap<usize, (f32, f32)> =
+            arrange(&pinned).into_iter().collect();
+        assert!(!moved.contains_key(&0), "a pinned node moved");
+        let a = moved.get(&1).copied().unwrap_or(pinned[1].position);
+        assert_ne!(a, (0.0, 0.0), "a node was placed on top of the pinned one");
+        assert_eq!(a.1, 0.0, "the root still belongs on the top row");
+        let b = moved.get(&2).copied().unwrap_or(pinned[2].position);
+        assert_eq!(b.0, a.0, "the child should follow its parent's column");
+        assert_eq!(b.1, 1.0);
+    }
+
+    /// The command end to end, on a real project.
+    #[test]
+    fn test_the_layout_command_arranges_the_current_level() {
+        use crate::slots::{CONTENT_IDX, LEFT_MENUBAR_IDX};
+        let mut state = State::new(false);
+        let mut redraw = false;
+        for (template, x, y) in
+            [("Curve", 6.0, 7.0), ("Remesh", 2.0, 1.0), ("Subdivide", 9.0, 3.0)]
+        {
+            state
+                .apply_action(
+                    McpAction::AddNode {
+                        template_name: template.to_string(),
+                        name: None,
+                        x,
+                        y,
+                    },
+                    &mut redraw,
+                )
+                .unwrap_or_else(|e| panic!("add {template}: {e}"));
+        }
+        let n = state.current_dir().children.len();
+        let (curve, remesh, subdiv) = (n - 3, n - 2, n - 1);
+        let names: Vec<String> =
+            state.current_dir().children.iter().map(|c| c.name.clone()).collect();
+
+        // Wire them into a chain: curve -> remesh -> subdivide.
+        let set_input = |state: &mut State, slot: usize, value: &str| {
+            let dir = state.current_dir_mut();
+            if let Some(p) =
+                dir.children[slot].params.iter_mut().find(|p| p.name.eq_ignore_ascii_case("input"))
+            {
+                p.default = value.to_string();
+            }
+        };
+        set_input(&mut state, remesh, &names[curve]);
+        set_input(&mut state, subdiv, &names[remesh]);
+
+        state.focused_pane = LEFT_MENUBAR_IDX;
+        state.param_editor = CONTENT_IDX;
+        assert!(state.layout_current_level());
+
+        let pos = |state: &State, slot: usize| state.current_dir().children[slot].position;
+        assert_eq!(pos(&state, curve).1 + 1.0, pos(&state, remesh).1, "remesh should sit below curve");
+        assert_eq!(pos(&state, remesh).1 + 1.0, pos(&state, subdiv).1, "subdivide should sit below remesh");
+        assert_eq!(pos(&state, curve).0, pos(&state, remesh).0, "the chain should be one column");
+        assert_eq!(pos(&state, remesh).0, pos(&state, subdiv).0, "the chain should be one column");
+
+        // The meta node is a utility tree and stays where it was.
+        let meta = state.current_dir().children.iter().position(|c| c.node_type == "meta");
+        if let Some(m) = meta {
+            assert_eq!(pos(&state, m), (0.0, 0.0), "the meta node moved");
+        }
+
+        // Running it again changes nothing, and says so rather than looking
+        // broken.
+        let before: Vec<_> =
+            state.current_dir().children.iter().map(|c| c.position).collect();
+        assert!(state.layout_current_level());
+        let after: Vec<_> = state.current_dir().children.iter().map(|c| c.position).collect();
+        assert_eq!(before, after, "a second layout should be a no-op");
+
+        // And it is gated on the network pane like every other network command.
+        state.focused_pane = crate::slots::RIGHT_MENUBAR_IDX;
+        assert!(!state.layout_current_level());
+    }
+
     /// A page's raster is its physical size times its resolution — the
     /// property that makes DPI a page parameter rather than an export one.
     #[test]
diff --git a/src/shortcut.rs b/src/shortcut.rs
index cd53c5c..769c6dc 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -38,6 +38,8 @@ pub enum Action {
     NetworkPan(i32, i32),
     FrameCursor,
     FrameAll,
+    /// Arrange the current level's nodes from their wiring.
+    LayoutNodes,
 }
 
 #[derive(Debug, Clone)]