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

commita1416575f6f38199eb64881b61a9ce76e17df596
parent93c8a12cd1
authorLucas Galante <[email protected]>
date2026-09-19 00:55
feat(network): Escape deselects, and the deselect sticks

Clicking empty space used to clear the selection; in the overlay it now
orbits the camera, so the selection needed a key of its own. Escape runs
it last, after the context menus, the viewer state and
connection-cancel — Escape is this app's one "get me out" key, and each
of those is more immediate than a selection.

The difficulty is not the key, it is making a deselect hold. The
selection IS whatever sits in the grid cursor's cell — that is what
sync_cursor_and_selection means — and that sync runs on nearly every
frame where anything changed, so a bare set_selected_node(None) is put
straight back and the params pane never clears.

deselect_node remembers the CELL it happened in and the sync leaves that
one cell alone. A cell rather than a flag, so the suppression is exactly
as narrow as it should be: step the cursor anywhere else and selection
resumes by itself, and stepping back onto the node selects it again. A
selection arriving from anywhere else — a click, a load, the params
pane — spends the memory at the top of the same sync, or clicking the
very node you just deselected would clear itself again on that frame.
The test asserts that last case specifically; my first version put the
clear in read_panel_offsets, where it happened to work in the real frame
loop and failed the moment anything called the syncs in a different
order.

There is also a `deselect` command, shipped unbound so it is findable in
the palette and bindable through input.kdl. Not Ctrl+D: the plugin uses
that for deselect-all, but this app already gives it to Circular Pane,
and silently rebinding it seemed worse than leaving the choice open.

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

 CLAUDE.md       | 26 ++++++++++++++++++++---
 src/app.rs      | 50 +++++++++++++++++++++++++++++++++++++++++++
 src/command.rs  |  7 ++++++
 src/main.rs     | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/shortcut.rs |  2 ++
 5 files changed, 148 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 1af162e..5c7a795 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -492,10 +492,30 @@ That makes the pane's RECT useless as a hit test, and three things route off it:
   network holds, minus the floating panes.
 
 What changes for the user: a plain click on empty space is no longer the
-network's — so deselecting by clicking empty space is gone while the plate is
-off. It now ORBITS THE CAMERA instead (see below), which is what makes the
+network's — it ORBITS THE CAMERA instead (see below), which is what makes the
 overlay feel like a scene with a graph on it rather than a graph with a
-picture behind it.
+picture behind it. Deselecting is on Escape.
+
+### Deselecting has to stick
+
+The selection IS whatever sits in the grid cursor's cell — that is what
+`sync_cursor_and_selection` means — and that sync runs on nearly every frame
+where anything changed. So `set_selected_node(None)` alone does not deselect:
+it is put straight back on the next frame, and the pane never clears.
+
+`State::deselect_node` therefore remembers the CELL it happened in
+(`deselected_cell`), and the sync leaves that one cell alone. A cell rather
+than a flag, so the suppression is exactly as narrow as it should be: step the
+cursor anywhere else and selection resumes by itself, and stepping back onto
+the node selects it again. A selection arriving from anywhere else — a click, a
+load, the params pane — spends the memory at the top of the same sync, or
+clicking the very node you just deselected would clear itself again.
+
+Escape runs it LAST, after the context menus, the viewer state and
+connection-cancel: Escape is this app's one "get me out" key, and all of those
+are more immediate than a selection. There is also a `deselect` command, shipped
+UNBOUND so it is findable in the palette — deliberately not Ctrl+D, which the
+plugin uses for deselect-all but which this app already gives to Circular Pane.
 
 `ViewportSettings::network_plate` persists it, beside the viewport toggles
 rather than in the project's pane-state list: a pane's VISIBILITY belongs to
diff --git a/src/app.rs b/src/app.rs
index a6f0253..dff68d3 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1125,6 +1125,15 @@ pub struct State {
     /// first call is this process's own and every later one is a REPLACEMENT
     /// after a reconnect. Remembering is the only way to tell them apart.
     pub seen_renderer: bool,
+    /// The grid cell an explicit deselect happened in.
+    ///
+    /// The selection IS whatever sits in the cursor's cell — that is what
+    /// `sync_cursor_and_selection` means — so simply clearing it does not
+    /// stick: the sync runs on nearly every frame that changes anything and
+    /// puts it straight back. Remembering the cell lets the sync leave that one
+    /// alone, and only that one: navigate anywhere else and selection resumes,
+    /// which is why this is a cell rather than a flag.
+    pub deselected_cell: Option<(i32, i32)>,
     /// An in-flight camera orbit drag: the cursor position the last motion was
     /// measured from. `None` when no orbit drag is running.
     pub orbit_drag: Option<(f32, f32)>,
@@ -4172,6 +4181,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             sim_cache: crate::geometry::SimCache::default(),
             page_image: None,
             seen_renderer: false,
+            deselected_cell: None,
             orbit_drag: None,
             page_dirty: false,
             last_sim_frame: i32::MIN,
@@ -5335,6 +5345,23 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         true
     }
 
+    /// Clear the node selection, and make it stick.
+    ///
+    /// Returns false when nothing was selected, so Escape can fall through to
+    /// meaning nothing rather than reporting that it did something.
+    pub(crate) fn deselect_node(&mut self) -> bool {
+        if self.graph().selected_node().is_none() {
+            return false;
+        }
+        self.graph_mut().set_selected_node(None);
+        self.deselected_cell = Some((self.grid_cursor_col, self.grid_cursor_row));
+        if self.focused_widget == Some(CONTENT_IDX) {
+            self.focused_widget = None;
+        }
+        self.sync_parameters_pane();
+        true
+    }
+
     /// Move the grid cursor one cell, taking the selection with it.
     ///
     /// The cursor is the network pane's keyboard position: `sync_cursor_and_
@@ -5352,6 +5379,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         self.pan_velocity_y = 0.0;
         self.grid_cursor_col += dc;
         self.grid_cursor_row += dr;
+        self.deselected_cell = None;
         self.sync_cursor_and_selection();
         self.keep_cursor_in_view();
         true
@@ -5556,6 +5584,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // viewport toggle uses, rather than a save call of its own.
                 settings_changed = true;
             }
+            Action::Deselect => {
+                self.deselect_node();
+            }
             Action::LayoutNodes => {
                 self.layout_current_level();
             }
@@ -5791,6 +5822,20 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             }
         }
 
+        // A selection that arrived from anywhere else — a click, a load, the
+        // params pane — spends the remembered deselect. Otherwise clicking the
+        // very node you deselected would clear itself again on this sync.
+        if self.graph().selected_node().is_some() {
+            self.deselected_cell = None;
+        }
+
+        // An explicit deselect holds for the cell it happened in, and for no
+        // other: step away and selection resumes by itself.
+        let node_at_cursor_idx = match self.deselected_cell {
+            Some(cell) if cell == (self.grid_cursor_col, self.grid_cursor_row) => None,
+            _ => node_at_cursor_idx,
+        };
+
         if let Some(idx) = node_at_cursor_idx {
             self.graph_mut().set_selected_node(Some(idx));
             if self.focused_widget != Some(CONTENT_IDX) {
@@ -7106,6 +7151,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         return true;
                     }
                     self.graph_mut().cancel_connecting();
+                    // Last: clearing the selection. Escape is this app's one
+                    // "get me out" key, and the things above are all more
+                    // immediate than a selection — a menu you can see, a mode
+                    // you are in, a wire you are dragging.
+                    self.deselect_node();
                     return true;
                 }
                 // Delete/Backspace removes the curve tool's selected control
diff --git a/src/command.rs b/src/command.rs
index 971068d..d924c5d 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -115,6 +115,13 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "command_palette", label: "Command Palette", context: Context::Always, run: Run::Key(Action::CommandPalette), default_chord: Some("Ctrl+p") },
     Command { id: "toggle_configure", label: "Configure", context: Context::Always, run: Run::Key(Action::ToggleConfigure), default_chord: Some("Ctrl+,") },
 
+    // Escape already does this, handled inline with the rest of Escape's
+    // cascade, so the row ships unbound — it is here to be findable in the
+    // palette and bindable by anyone who wants a chord. NOT Ctrl+D, which the
+    // plugin uses for deselect-all but which this app already gives to
+    // Circular Pane.
+    Command { id: "deselect", label: "Deselect", context: Context::Network, run: Run::Key(Action::Deselect), default_chord: None },
+
     // --- Network navigation ---
     //
     // The plugin's scheme, ported: hjkl rather than arrows (the arrows are the
diff --git a/src/main.rs b/src/main.rs
index 763c38b..c14df08 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4701,6 +4701,72 @@ mod tests {
 
     }
 
+    /// Deselecting has to STICK, which is the whole difficulty.
+    ///
+    /// The selection is whatever sits in the cursor's cell — that is what
+    /// `sync_cursor_and_selection` means — and the sync runs on nearly every
+    /// frame that changes anything. A bare `set_selected_node(None)` is put
+    /// straight back, so the deselected cell is remembered and the sync leaves
+    /// that one cell alone.
+    #[test]
+    fn test_deselect_sticks_until_the_cursor_moves() {
+        use crate::slots::{CONTENT_IDX, LEFT_MENUBAR_IDX};
+        let mut state = State::new(false);
+        let mut redraw = false;
+        state
+            .apply_action(
+                McpAction::AddNode { template_name: "Sphere".to_string(), name: None, x: 6.0, y: 6.0 },
+                &mut redraw,
+            )
+            .expect("add node");
+        let slot = state.current_dir().children.len() - 1;
+
+        state.focused_pane = LEFT_MENUBAR_IDX;
+        state.param_editor = CONTENT_IDX;
+        state.grid_cursor_col = 6;
+        state.grid_cursor_row = 6;
+        state.sync_cursor_and_selection();
+        assert_eq!(state.graph().selected_node(), Some(slot));
+
+        // Nothing selected, nothing to do — so Escape can fall through to
+        // meaning nothing rather than claiming it acted.
+        assert!(state.deselect_node());
+        assert_eq!(state.graph().selected_node(), None);
+        assert!(!state.deselect_node(), "a second deselect has nothing to clear");
+
+        // THE point: the sync that runs on the next changed frame must not put
+        // it back, even though the cursor still sits on the node.
+        state.sync_cursor_and_selection();
+        assert_eq!(
+            state.graph().selected_node(),
+            None,
+            "the sync re-selected the node the user just deselected"
+        );
+
+        // Stepping away and back selects again — the deselect held for that
+        // one cell, not for the node.
+        assert!(state.run_command("nav_right"));
+        assert_eq!(state.graph().selected_node(), None, "nothing is at the new cell");
+        assert!(state.run_command("nav_left"));
+        assert_eq!(
+            state.graph().selected_node(),
+            Some(slot),
+            "coming back to the node should select it again"
+        );
+
+        // And selecting explicitly spends the memory: deselect, then select
+        // the same node, and the next sync must leave it selected.
+        assert!(state.deselect_node());
+        state.graph_mut().set_selected_node(Some(slot));
+        state.sync_layout();
+        state.sync_cursor_and_selection();
+        assert_eq!(
+            state.graph().selected_node(),
+            Some(slot),
+            "re-selecting the deselected node did not stick"
+        );
+    }
+
     /// 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 6c4cc5d..30e451f 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -42,6 +42,8 @@ pub enum Action {
     LayoutNodes,
     /// Draw the network pane's plate, or let the graph overlay the scene.
     ToggleNetworkPlate,
+    /// Clear the node selection.
+    Deselect,
 }
 
 #[derive(Debug, Clone)]