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

commit5bd23b8dcfe81ea24ae27bd23914657acd308bf8
parent08b4caed56
authorLucas Galante <[email protected]>
date2026-09-03 14:51
feat: undo/redo in the curve viewer state

Ctrl+Z / Ctrl+Shift+Z (input.kdl `undo` / `redo`, and Edit ▸ Undo/Redo,
which were inert menu rows until now) step the edited curve's point list
back and forward through a per-tool history of snapshots. One entry per
gesture: a drag records on its first motion — a grab released without
moving leaves nothing — an add-and-drag is one step, a delete is one.
A new gesture after an undo drops the redo branch; a step mid-drag
abandons the drag and clamps the selection. The history lives on the
CurveTool, so it goes when the state exits. Edits from outside the tool
(params pane, curve_set_points over MCP) are not recorded, but an undo
still restores the pre-gesture list.

The Undo/Redo actions dispatch from any pane after the param pane's
shot, like the transport keys; with no tool active they consume and do
nothing. execute_action is where a project-wide history would be
consulted once one exists.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SjC6ZXH9Z31WwLCLMjyBcd

 CLAUDE.md         |   7 +++-
 src/app.rs        |  31 ++++++++++++++
 src/curve_tool.rs |  99 ++++++++++++++++++++++++++++++++++++++++++---
 src/main.rs       | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/shortcut.rs   |   2 +
 5 files changed, 251 insertions(+), 6 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8d68513..7a4d38a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -172,7 +172,12 @@ gone from cce-ui with the wgpu path).
   `cursor_in_viewport() && !in_network_pane`, so the network plate keeps its
   clicks where they overlap), motion at the top of CursorMoved, Escape ahead of
   connection-cancel. The tool binds the node by ID and deactivates lazily when
-  the id no longer resolves to a curve.
+  the id no longer resolves to a curve. Undo/redo (`Action::Undo`/`Redo`,
+  `Ctrl+Z`/`Ctrl+Shift+Z` via input.kdl `undo`/`redo`, and Edit ▸ Undo/Redo)
+  are point-list snapshots on the tool, one per gesture — a drag records on
+  its first motion, so a no-move click leaves nothing. There is no
+  project-wide history yet; `execute_action` is where one would be consulted
+  after the tool declines.
 - `src/project.rs` — save/load. A project is a **directory containing `state.json`**
   (`Project { name, root: FsNode, view_state }`); `default_project.json` in the crate
   root is special-cased as a single file and doubles as the detached-window sync channel.
diff --git a/src/app.rs b/src/app.rs
index 20af77a..e92d4c9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2147,6 +2147,12 @@ impl State {
             "Save As" => {
                 self.save_file_chooser();
             }
+            "Undo" => {
+                self.execute_action(Action::Undo);
+            }
+            "Redo" => {
+                self.execute_action(Action::Redo);
+            }
             "Exit" => {
                 self.exit_requested = true;
             }
@@ -3610,6 +3616,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             register("play_pause_reverse", "Down", Action::PlayPauseReverse);
             register("frame_next", "Right", Action::FrameNext);
             register("frame_prev", "Left", Action::FramePrev);
+            register("undo", "Ctrl+z", Action::Undo);
+            register("redo", "Ctrl+Shift+z", Action::Redo);
         }
 
         let mut state = Self {
@@ -4634,6 +4642,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
     pub fn execute_action(&mut self, action: Action) {
         let mut settings_changed = false;
         match action {
+            // Undo/Redo reach whichever editing state owns a history. The
+            // curve viewer state is the only one so far; when the app grows
+            // a project-wide history this is where it would be consulted
+            // after the tool declines.
+            Action::Undo => {
+                self.curve_tool_undo();
+            }
+            Action::Redo => {
+                self.curve_tool_redo();
+            }
             Action::ToggleGrid => {
                 let val = !self.viewport().show_grid;
                 self.viewport_mut().show_grid = val;
@@ -6139,6 +6157,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 {
                     return true;
                 }
+                // Undo/Redo dispatch from any pane, like the transport above
+                // and after the param pane's shot for the same reason. Today
+                // the only history is the curve viewer state's; with no tool
+                // active the chord is consumed and does nothing, rather than
+                // falling through to become a stray "z" somewhere.
+                if event.state == ElementState::Pressed {
+                    if let Some(action @ (Action::Undo | Action::Redo)) =
+                        self.shortcut_manager.match_action(&self.modifiers, &event.logical_key)
+                    {
+                        self.execute_action(action);
+                        return true;
+                    }
+                }
                 let mut changed = false;
                 if event.state == ElementState::Pressed {
                         let is_plain_key = !self.modifiers.control_key() && !self.modifiers.alt_key() && !self.modifiers.super_key();
diff --git a/src/curve_tool.rs b/src/curve_tool.rs
index 3f3e19b..66cde23 100644
--- a/src/curve_tool.rs
+++ b/src/curve_tool.rs
@@ -12,6 +12,14 @@
 //!   depth of the last control point, and immediately drags it;
 //! - **right press on a handle** deletes that point; Delete/Backspace deletes
 //!   the selected (last-clicked) one;
+//! - **Ctrl+Z / Ctrl+Shift+Z** undo and redo, one gesture at a time: a whole
+//!   drag is one step (recorded on the first motion after a grab, so a click
+//!   that never moves records nothing), an add-and-drag is one step, a delete
+//!   is one step. The history is snapshots of the point list, held on the tool
+//!   itself — it lives exactly as long as the state does. Edits that arrive
+//!   from outside the tool (the params pane, `curve_set_points` over MCP) are
+//!   not recorded, but an undo still restores the list as it was before the
+//!   last tool gesture, whatever happened since;
 //! - **Escape** exits the state.
 //!
 //! Edits write the node's "Points" param through the same resync sequence as
@@ -27,6 +35,9 @@ use glam::{Mat4, Vec3, Vec4};
 /// How close (logical px) a press must land to a projected handle to grab it.
 pub const HANDLE_HIT_RADIUS: f32 = 10.0;
 
+/// Undo depth kept per tool session; older snapshots fall off the front.
+pub const HISTORY_LIMIT: usize = 256;
+
 pub struct CurveTool {
     /// Id of the curve node being edited.
     pub node_id: String,
@@ -34,13 +45,38 @@ pub struct CurveTool {
     pub selected: Option<usize>,
     /// An in-flight drag, if a press grabbed (or just added) a handle.
     pub drag: Option<CurveDrag>,
+    /// Point lists as they were before each recorded gesture, oldest first.
+    pub undo: Vec<Vec<Vec3>>,
+    /// Point lists undone and not yet redone, oldest first. Cleared by any
+    /// new gesture.
+    pub redo: Vec<Vec<Vec3>>,
+}
+
+impl CurveTool {
+    pub fn new(node_id: String) -> Self {
+        CurveTool { node_id, selected: None, drag: None, undo: Vec::new(), redo: Vec::new() }
+    }
+
+    /// Record `before` as the state to return to on the next undo. Any new
+    /// gesture forks the history, so the redo stack goes.
+    fn record(&mut self, before: Vec<Vec3>) {
+        self.undo.push(before);
+        self.redo.clear();
+        if self.undo.len() > HISTORY_LIMIT {
+            let excess = self.undo.len() - HISTORY_LIMIT;
+            self.undo.drain(..excess);
+        }
+    }
 }
 
-#[derive(Clone, Copy)]
+#[derive(Clone)]
 pub struct CurveDrag {
     pub point: usize,
     /// NDC depth captured at grab time; motion unprojects onto this plane.
     pub ndc_z: f32,
+    /// The point list before the grab, until the first motion records it.
+    /// A grab that releases without moving leaves no history entry.
+    pub before: Option<Vec<Vec3>>,
 }
 
 /// World → (screen x, screen y, ndc z) through the cached scene mvp.
@@ -117,7 +153,7 @@ impl State {
         if self.curve_tool.as_ref().map(|t| t.node_id == id).unwrap_or(false) {
             self.curve_tool = None;
         } else {
-            self.curve_tool = Some(CurveTool { node_id: id, selected: None, drag: None });
+            self.curve_tool = Some(CurveTool::new(id));
         }
     }
 
@@ -196,7 +232,7 @@ impl State {
         if let Some((idx, ndc_z)) = self.curve_tool_handle_at_cursor() {
             let tool = self.curve_tool.as_mut().expect("checked above");
             tool.selected = Some(idx);
-            tool.drag = Some(CurveDrag { point: idx, ndc_z });
+            tool.drag = Some(CurveDrag { point: idx, ndc_z, before: Some(pts) });
             return true;
         }
         // Empty space: add a point. Depth comes from the last control point
@@ -217,12 +253,16 @@ impl State {
         let Some(world) = unproject_point(&mvp, view, self.cursor_x, self.cursor_y, ndc_z) else {
             return true;
         };
+        let before = pts.clone();
         pts.push(world);
         let idx = pts.len() - 1;
         self.set_curve_points(&node_id, &pts);
         if let Some(tool) = self.curve_tool.as_mut() {
+            // The add is the recorded step; the drag that follows it is
+            // part of the same gesture, so `before` stays None.
+            tool.record(before);
             tool.selected = Some(idx);
-            tool.drag = Some(CurveDrag { point: idx, ndc_z });
+            tool.drag = Some(CurveDrag { point: idx, ndc_z, before: None });
         }
         true
     }
@@ -230,7 +270,7 @@ impl State {
     /// Pointer motion during a grab: the point tracks the cursor on the
     /// camera-facing plane at its grab depth.
     pub(crate) fn curve_tool_drag_motion(&mut self) -> bool {
-        let Some(drag) = self.curve_tool.as_ref().and_then(|t| t.drag) else {
+        let Some(drag) = self.curve_tool.as_ref().and_then(|t| t.drag.clone()) else {
             return false;
         };
         let Some(mvp) = self.last_scene_mvp else {
@@ -250,6 +290,11 @@ impl State {
             return false;
         };
         pts[drag.point] = world;
+        if let Some(tool) = self.curve_tool.as_mut() {
+            if let Some(before) = tool.drag.as_mut().and_then(|d| d.before.take()) {
+                tool.record(before);
+            }
+        }
         self.set_curve_points(&node_id, &pts);
         true
     }
@@ -295,9 +340,11 @@ impl State {
         if idx >= pts.len() {
             return false;
         }
+        let before = pts.clone();
         pts.remove(idx);
         self.set_curve_points(&node_id, &pts);
         if let Some(tool) = self.curve_tool.as_mut() {
+            tool.record(before);
             tool.drag = None;
             // Keep a neighbor selected so repeated Delete walks the curve.
             tool.selected = if pts.is_empty() {
@@ -308,4 +355,46 @@ impl State {
         }
         true
     }
+
+    /// Ctrl+Z: return the points to how they were before the last recorded
+    /// gesture. Consumes only when the tool is active and has history.
+    pub(crate) fn curve_tool_undo(&mut self) -> bool {
+        self.curve_tool_step(true)
+    }
+
+    /// Ctrl+Shift+Z: reapply the last undone gesture.
+    pub(crate) fn curve_tool_redo(&mut self) -> bool {
+        self.curve_tool_step(false)
+    }
+
+    fn curve_tool_step(&mut self, undo: bool) -> bool {
+        let Some(tool) = &self.curve_tool else {
+            return false;
+        };
+        let node_id = tool.node_id.clone();
+        let Some(current) = self.curve_node_points(&node_id) else {
+            self.curve_tool = None;
+            return false;
+        };
+        let tool = self.curve_tool.as_mut().expect("checked above");
+        let (from, to) = if undo {
+            (&mut tool.undo, &mut tool.redo)
+        } else {
+            (&mut tool.redo, &mut tool.undo)
+        };
+        let Some(target) = from.pop() else {
+            return false;
+        };
+        to.push(current);
+        // A step mid-drag abandons the drag: the grabbed index may not
+        // exist in the restored list, and the pointer no longer means
+        // anything to it.
+        tool.drag = None;
+        tool.selected = match tool.selected {
+            Some(i) if !target.is_empty() => Some(i.min(target.len() - 1)),
+            _ => None,
+        };
+        self.set_curve_points(&node_id, &target);
+        true
+    }
 }
diff --git a/src/main.rs b/src/main.rs
index d0a3d6e..95f90c3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1558,6 +1558,124 @@ mod tests {
         assert!(state.curve_tool.is_none());
     }
 
+    /// Undo/redo in the curve viewer state: one entry per gesture (a drag
+    /// records once on its first motion, an add-and-drag once, a delete
+    /// once; a grab released without moving records nothing), undo walks
+    /// back through them, redo forward, and a fresh gesture after an undo
+    /// drops the redo branch. Same identity-mvp setup as the tool test.
+    #[test]
+    fn test_curve_tool_undo_redo() {
+        let mut state = State::new(false);
+        let mut redraw = false;
+        state
+            .apply_action(
+                McpAction::AddNode { template_name: "Curve".to_string(), name: None, x: 0.0, y: 0.0 },
+                &mut redraw,
+            )
+            .expect("add curve node");
+        let slot = state.current_dir().children.len() - 1;
+        state.toggle_curve_tool(slot);
+        state.last_scene_mvp = Some(Mat4::IDENTITY);
+        state.last_scene_view_rect = (0.0, 0.0, 100.0, 100.0);
+        let sx = |x: f32| 50.0 + x * 50.0;
+        let sy = |y: f32| 50.0 - y * 50.0;
+        let points_of = |state: &State| {
+            crate::geometry::parse_curve_points(&crate::geometry::node_param_str(
+                &state.current_dir().children[slot],
+                "Points",
+                "",
+            ))
+        };
+        let history = |state: &State| {
+            let t = state.curve_tool.as_ref().expect("tool active");
+            (t.undo.len(), t.redo.len())
+        };
+
+        let initial = points_of(&state);
+        assert!(!state.curve_tool_undo(), "nothing to undo yet");
+        assert!(!state.curve_tool_redo(), "nothing to redo yet");
+
+        // Grab and release without moving: no history.
+        state.cursor_x = sx(initial[0].x);
+        state.cursor_y = sy(initial[0].y);
+        assert!(state.curve_tool_press());
+        assert!(state.curve_tool_release());
+        assert_eq!(history(&state), (0, 0));
+
+        // Gesture 1: drag the first point to the pane center, over several
+        // motion events — still one entry.
+        assert!(state.curve_tool_press());
+        for (x, y) in [(55.0, 55.0), (52.0, 52.0), (50.0, 50.0)] {
+            state.cursor_x = x;
+            state.cursor_y = y;
+            assert!(state.curve_tool_drag_motion());
+        }
+        assert!(state.curve_tool_release());
+        let after_drag = points_of(&state);
+        assert!(after_drag[0].length() < 1e-4);
+        assert_eq!(history(&state), (1, 0));
+
+        // Gesture 2: add a point (press on empty space + drag + release).
+        state.cursor_x = 90.0;
+        state.cursor_y = 90.0;
+        assert!(state.curve_tool_press());
+        state.cursor_x = 85.0;
+        state.cursor_y = 85.0;
+        assert!(state.curve_tool_drag_motion());
+        assert!(state.curve_tool_release());
+        let after_add = points_of(&state);
+        assert_eq!(after_add.len(), initial.len() + 1);
+        assert_eq!(history(&state), (2, 0));
+
+        // Gesture 3: delete the selected (new) point.
+        assert!(state.curve_tool_delete_selected());
+        let after_delete = points_of(&state);
+        assert_eq!(after_delete.len(), initial.len());
+        assert_eq!(history(&state), (3, 0));
+
+        // Undo walks back through all three.
+        assert!(state.curve_tool_undo());
+        assert_eq!(points_of(&state), after_add);
+        assert!(state.curve_tool_undo());
+        assert_eq!(points_of(&state), after_drag);
+        assert!(state.curve_tool_undo());
+        assert_eq!(points_of(&state), initial);
+        assert_eq!(history(&state), (0, 3));
+        assert!(!state.curve_tool_undo(), "history exhausted");
+
+        // Redo walks forward again.
+        assert!(state.curve_tool_redo());
+        assert_eq!(points_of(&state), after_drag);
+        assert!(state.curve_tool_redo());
+        assert_eq!(points_of(&state), after_add);
+        assert_eq!(history(&state), (2, 1));
+
+        // A new gesture after an undo forks: the redo branch is gone.
+        state.cursor_x = 50.0;
+        state.cursor_y = 50.0;
+        assert!(state.curve_tool_delete_at_cursor());
+        assert_eq!(history(&state), (3, 0));
+        assert!(!state.curve_tool_redo());
+
+        // Undo mid-drag abandons the drag and clamps the selection.
+        let pts = points_of(&state);
+        state.cursor_x = sx(pts[pts.len() - 1].x);
+        state.cursor_y = sy(pts[pts.len() - 1].y);
+        assert!(state.curve_tool_press());
+        state.cursor_x += 5.0;
+        assert!(state.curve_tool_drag_motion());
+        assert!(state.curve_tool_undo());
+        let tool = state.curve_tool.as_ref().unwrap();
+        assert!(tool.drag.is_none());
+        assert!(tool.selected.map(|i| i < points_of(&state).len()).unwrap_or(true));
+        assert!(!state.curve_tool_drag_motion(), "no drag survives an undo");
+
+        // Leaving the state drops its history.
+        state.toggle_curve_tool(slot);
+        assert!(state.curve_tool.is_none());
+        assert!(!state.curve_tool_undo());
+    }
+
     /// The Extrude template: a subnet (input -> opencl -> output) whose kernel
     /// offsets each input triangle along its face normal and stitches side
     /// walls. Per input triangle it emits top (3) + walls (18) + base (3) =
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 485f335..b55e338 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -20,6 +20,8 @@ pub enum Action {
     PlayPauseReverse,
     FrameNext,
     FramePrev,
+    Undo,
+    Redo,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]