graphic design tool
git clone https://git.lucas.co/cce-designer.git
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)]