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

commitbab75a92eb0c92ddedcd6fd6d6f54cbf63e3974c
parent5bd23b8dcf
authorLucas Galante <[email protected]>
date2026-09-03 15:00
refactor: curve viewer state undo rides the toolkit's History and routing

The tool's hand-rolled stacks become a `cce_ui::history::History` of
point-list snapshots (a grab opens a gesture the first motion commits;
release cancels an uncommitted one), and the chords arrive through the
new `Application::undo`/`redo` hooks — the runner routes them after the
focused text box declines, so a param field being edited undoes its own
typing and the curve tool gets the chord otherwise. The app-local
Ctrl+Z dispatch and chord registrations go; `Action::Undo`/`Redo` stay
for the Edit menu rows.

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

 CLAUDE.md          | 11 +++++---
 src/app.rs         | 23 ++++------------
 src/application.rs | 18 +++++++++++++
 src/curve_tool.rs  | 79 ++++++++++++++++++++----------------------------------
 src/main.rs        |  2 +-
 5 files changed, 60 insertions(+), 73 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7a4d38a..6af308b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -172,10 +172,13 @@ 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. 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
+  the id no longer resolves to a curve. Undo/redo is a
+  `cce_ui::history::History` of point-list snapshots on the tool, one per
+  gesture (a drag opens a gesture its first motion commits, so a no-move
+  click leaves nothing). The chords are the toolkit's (`undo`/`redo` in
+  input.kdl, routed by the runner to `Application::undo`/`redo` in
+  `application.rs` after the focused text box declines); Edit ▸ Undo/Redo
+  reach the same code through `Action::Undo`/`Redo`. 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`**
diff --git a/src/app.rs b/src/app.rs
index e92d4c9..4de3d82 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3616,8 +3616,6 @@ 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 {
@@ -4643,9 +4641,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         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.
+            // chords arrive through `Application::undo` / `redo` (the
+            // toolkit routes them, after the focused text box's turn); the
+            // Edit menu rows come here directly. The curve viewer state is
+            // the only history so far; a project-wide one would be consulted
+            // here after the tool declines.
             Action::Undo => {
                 self.curve_tool_undo();
             }
@@ -6157,19 +6157,6 @@ 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/application.rs b/src/application.rs
index 128aa02..09f8f69 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -315,6 +315,24 @@ impl Application for State {
         true
     }
 
+    /// The toolkit's undo/redo routing lands here once no focused text box
+    /// wanted the chord. Only the curve viewer state has a history today.
+    fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
+        let taken = self.curve_tool_undo();
+        if taken {
+            *needs_rebuild = true;
+        }
+        taken
+    }
+
+    fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
+        let taken = self.curve_tool_redo();
+        if taken {
+            *needs_rebuild = true;
+        }
+        taken
+    }
+
     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<CustomEvent> {
         self.sync_modifiers_from_ctx();
         let ev = WindowEvent::KeyboardInput { event: event.clone() };
diff --git a/src/curve_tool.rs b/src/curve_tool.rs
index 66cde23..2d8cbfd 100644
--- a/src/curve_tool.rs
+++ b/src/curve_tool.rs
@@ -12,14 +12,16 @@
 //!   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;
+//! - **undo / redo** (the toolkit chords, routed through `Application::undo`
+//!   / `redo` once no focused text box wants them) step 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 a
+//!   `cce_ui::history::History` of point-list snapshots 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
@@ -30,14 +32,12 @@
 
 use crate::app::{FsNode, State};
 use crate::geometry::{format_curve_points, node_param_str, parse_curve_points};
+use cce_ui::history::History;
 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,
@@ -45,38 +45,22 @@ 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>>,
+    /// Point-list snapshots, one per gesture. A grab opens a gesture that
+    /// the first motion commits, so a click that never moves records nothing.
+    pub history: History<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);
-        }
+        CurveTool { node_id, selected: None, drag: None, history: History::new() }
     }
 }
 
-#[derive(Clone)]
+#[derive(Clone, Copy)]
 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.
@@ -232,7 +216,8 @@ 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, before: Some(pts) });
+            tool.history.begin_gesture(pts);
+            tool.drag = Some(CurveDrag { point: idx, ndc_z });
             return true;
         }
         // Empty space: add a point. Depth comes from the last control point
@@ -259,10 +244,10 @@ impl State {
         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);
+            // part of the same gesture, so no gesture is opened for it.
+            tool.history.record(before);
             tool.selected = Some(idx);
-            tool.drag = Some(CurveDrag { point: idx, ndc_z, before: None });
+            tool.drag = Some(CurveDrag { point: idx, ndc_z });
         }
         true
     }
@@ -270,7 +255,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.clone()) else {
+        let Some(drag) = self.curve_tool.as_ref().and_then(|t| t.drag) else {
             return false;
         };
         let Some(mvp) = self.last_scene_mvp else {
@@ -291,9 +276,7 @@ impl State {
         };
         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);
-            }
+            tool.history.commit_gesture();
         }
         self.set_curve_points(&node_id, &pts);
         true
@@ -304,6 +287,7 @@ impl State {
         match self.curve_tool.as_mut() {
             Some(tool) if tool.drag.is_some() => {
                 tool.drag = None;
+                tool.history.cancel_gesture();
                 true
             }
             _ => false,
@@ -344,7 +328,7 @@ impl State {
         pts.remove(idx);
         self.set_curve_points(&node_id, &pts);
         if let Some(tool) = self.curve_tool.as_mut() {
-            tool.record(before);
+            tool.history.record(before);
             tool.drag = None;
             // Keep a neighbor selected so repeated Delete walks the curve.
             tool.selected = if pts.is_empty() {
@@ -356,13 +340,13 @@ impl State {
         true
     }
 
-    /// Ctrl+Z: return the points to how they were before the last recorded
+    /// Undo: 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.
+    /// Redo: reapply the last undone gesture.
     pub(crate) fn curve_tool_redo(&mut self) -> bool {
         self.curve_tool_step(false)
     }
@@ -377,15 +361,10 @@ impl State {
             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 {
+        let stepped = if undo { tool.history.undo(current) } else { tool.history.redo(current) };
+        let Some(target) = stepped 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.
diff --git a/src/main.rs b/src/main.rs
index 95f90c3..264e634 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1588,7 +1588,7 @@ mod tests {
         };
         let history = |state: &State| {
             let t = state.curve_tool.as_ref().expect("tool active");
-            (t.undo.len(), t.redo.len())
+            (t.history.undo_len(), t.history.redo_len())
         };
 
         let initial = points_of(&state);