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

commit52f008f79711425d4012070041584ac4418e601c
parent041a49e6e0
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 19:13
feat: Reset Parameters is undoable

A parameter history beside the text boxes' and the viewer states': a
node's parameter list as it stood before a command rewrote it, by node
id. Undo and Redo consult it after a code row and a viewer state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md            | 18 +++++++++++++
 src/app.rs           | 46 ++++++++++++++++++++++++++++++----
 src/application.rs   | 11 +++++---
 src/main.rs          | 62 +++++++++++++++++++++++++++++++++++++++++++++
 src/param_history.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs       |  2 ++
 6 files changed, 202 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index db197bc..f08eddd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -130,6 +130,24 @@ template's defaults with their numbers scaled by half again, a stand-in
 that stored nothing and read nothing of the node's own values.
 `the_cameras_and_the_parameter_reset_are_commands` is the test.
 
+**Reset Parameters is undoable** (`src/param_history.rs`, the same day),
+which makes it the first thing outside a text box or a viewer state that
+is. `State::param_history` holds one kind of step: a node's whole
+parameter list as it stood before a command rewrote it, by node ID, so a
+rename in between does not lose it and a deleted node drops its step with
+a line on the status bar. A snapshot of ONE node, not of the tree:
+restoring the tree would take back every edit made anywhere since, none of
+which are recorded. Undo and Redo consult it LAST — a code row, then a
+viewer state, then this (`Application::undo` for the chord,
+`Action::Undo` for the palette) — so the order across the three is by
+owner and not by time. New Project and Open clear it; the sync channel's
+reload does not, the nodes being the same ones. It is not cce-ui's
+`History` because that has no way to look at a step before taking it, and
+what is filed for redo is the current state of the node the step names.
+Any other command that rewrites a node's parameters at once records the
+same way: a `ParamSnapshot` to `param_history.record` before the write.
+`reset_parameters_is_undone_and_redone` is the test.
+
 (The former bespoke HTTP API on port 3000 was retired in favor of this;
 app-internal threads like the cce-files choosers now return results via
 `CustomEvent::RunAction` instead of POSTing to it.)
diff --git a/src/app.rs b/src/app.rs
index f6e3b2d..bca10b2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2059,6 +2059,10 @@ pub struct State {
     /// Copy Parameter's clipboard: (node id, parameter name). An id, so a
     /// rename between the copy and the paste still pastes the right path.
     pub copied_param: Option<(String, String)>,
+    /// Undo for commands that rewrite a node's parameters whole — see
+    /// `src/param_history.rs`. Consulted after a code row and a viewer
+    /// state have had their turn.
+    pub param_history: crate::param_history::ParamHistory,
     /// The network editor's right-click menu — the same thread-local again,
     /// with the flag saying the open menu is this one.
     pub network_menu_active: bool,
@@ -3121,12 +3125,18 @@ impl State {
             return false;
         }
         let node = &mut self.param_editor_dir_mut().children[slot];
+        let before = crate::param_history::ParamSnapshot {
+            node_id: node.id.clone(),
+            params: node.params.clone(),
+            what: "Reset Parameters",
+        };
         for (pname, text, is_expr) in defaults {
             if let Some(p) = node.params.iter_mut().find(|p| p.name == pname) {
                 p.set_text(text);
                 p.set_expr(is_expr);
             }
         }
+        self.param_history.record(before);
         self.sync_nodes();
         self.rebuild_scene_geometry();
         self.sync_parameters_pane();
@@ -3134,6 +3144,31 @@ impl State {
         true
     }
 
+    /// Undo (or redo) the last command that rewrote a node's parameters.
+    /// False when there is no such step, so the caller can say nothing was
+    /// taken.
+    pub fn param_history_step(&mut self, undo: bool) -> bool {
+        let Some(step) = self.param_history.take(undo) else { return false };
+        let verb = if undo { "Undo" } else { "Redo" };
+        let Some(node) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, &step.node_id) else {
+            // Deleted since. The step names nothing, and is dropped.
+            self.update_status_text(&format!("{verb} {}: the node is gone", step.what));
+            return false;
+        };
+        let replaced = crate::param_history::ParamSnapshot {
+            node_id: step.node_id.clone(),
+            params: std::mem::replace(&mut node.params, step.params),
+            what: step.what,
+        };
+        let name = node.name.clone();
+        self.param_history.file(undo, replaced);
+        self.sync_nodes();
+        self.rebuild_scene_geometry();
+        self.sync_parameters_pane();
+        self.update_status_text(&format!("{verb} {}: {name}", step.what));
+        true
+    }
+
     pub fn cursor_in_viewport(&self) -> bool {
         if self.network_overlay() {
             // The complement of the overlay: everything in the body the
@@ -6647,6 +6682,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             param_menu_actions: Vec::new(),
             param_menu_target: None,
             copied_param: None,
+            param_history: Default::default(),
             network_menu_active: false,
             network_menu_actions: Vec::new(),
             sim_cache: crate::geometry::SimCache::default(),
@@ -8408,14 +8444,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             // Undo/Redo reach whichever editing state owns a history. The
             // 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.
+            // Edit menu rows come here directly. A viewer state's history
+            // first, then the parameter history (`src/param_history.rs`);
+            // a project-wide one would be consulted after both decline.
             Action::Undo => {
-                self.viewer_tool_undo();
+                let _ = self.viewer_tool_undo() || self.param_history_step(true);
             }
             Action::Redo => {
-                self.viewer_tool_redo();
+                let _ = self.viewer_tool_redo() || self.param_history_step(false);
             }
             Action::ToggleGrid => {
                 let val = !self.viewport().show_grid;
diff --git a/src/application.rs b/src/application.rs
index c926671..ba194fe 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -321,11 +321,14 @@ impl Application for State {
     }
 
     /// 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.
+    /// wanted the chord: a code row, a viewer state, then the parameter
+    /// history, the first with something to take back.
     fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
         // A code row being edited owns the chord: its typing is the thing to
         // undo, ahead of a viewer tool that may also be active.
-        let taken = self.code_editor_action(cce_ui::widget::ContextAction::Undo) || self.viewer_tool_undo();
+        let taken = self.code_editor_action(cce_ui::widget::ContextAction::Undo)
+            || self.viewer_tool_undo()
+            || self.param_history_step(true);
         if taken {
             *needs_rebuild = true;
         }
@@ -333,7 +336,9 @@ impl Application for State {
     }
 
     fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
-        let taken = self.code_editor_action(cce_ui::widget::ContextAction::Redo) || self.viewer_tool_redo();
+        let taken = self.code_editor_action(cce_ui::widget::ContextAction::Redo)
+            || self.viewer_tool_redo()
+            || self.param_history_step(false);
         if taken {
             *needs_rebuild = true;
         }
diff --git a/src/main.rs b/src/main.rs
index 613925b..5d3d114 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
 
 pub mod app;
 pub mod param;
+pub mod param_history;
 pub mod application;
 pub mod curve_tool;
 pub mod soft_transform_tool;
@@ -6712,6 +6713,67 @@ mod tests {
         state.run_command("reset_parameters");
     }
 
+    /// Reset Parameters can be taken back, and put back again. It rewrites
+    /// every parameter of a node at once, and until it recorded a step the
+    /// values it replaced were gone.
+    #[test]
+    fn reset_parameters_is_undone_and_redone() {
+        let mut state = State::new(false);
+        let slot = state
+            .current_dir()
+            .children
+            .iter()
+            .position(|c| c.node_type == "sphere")
+            .expect("the bundled project has a sphere");
+        state.graph_mut().set_selected_node(Some(slot));
+        let texts = |state: &State| -> Vec<(String, String, bool)> {
+            state.current_dir().children[slot]
+                .params
+                .iter()
+                .map(|p| (p.name.clone(), p.text().to_string(), p.is_expr()))
+                .collect()
+        };
+        {
+            let node = &mut state.current_dir_mut().children[slot];
+            node.params.iter_mut().find(|p| p.name == "Radius").unwrap().set_text("3.25".to_string());
+            let rows = node.params.iter_mut().find(|p| p.name == "Rows").unwrap();
+            rows.set_text("$F + 4".to_string());
+            rows.set_expr(true);
+        }
+        let edited = texts(&state);
+
+        assert!(!state.param_history_step(true), "nothing to undo before the reset");
+        assert!(state.run_command("reset_parameters"));
+        let reset = texts(&state);
+        assert_ne!(reset, edited);
+
+        // Through the Undo command, as the palette and the chord arrive.
+        assert!(state.run_command("undo"));
+        assert_eq!(texts(&state), edited, "undo did not bring the values back, expression flag included");
+        assert!(state.run_command("redo"));
+        assert_eq!(texts(&state), reset);
+        assert!(state.run_command("undo"));
+        assert_eq!(texts(&state), edited);
+
+        // A rename between the reset and the undo: the step is by id.
+        state.run_command("reset_parameters");
+        state.current_dir_mut().children[slot].name = "ball".to_string();
+        assert!(state.param_history_step(true));
+        assert_eq!(texts(&state), edited);
+
+        // A node that is gone takes its step with it, and says so.
+        state.run_command("reset_parameters");
+        state.current_dir_mut().children.remove(slot);
+        assert!(!state.param_history_step(true));
+
+        // Another document's steps are not this one's.
+        let mut state = State::new(false);
+        state.graph_mut().set_selected_node(Some(slot));
+        state.run_command("reset_parameters");
+        state.new_project();
+        assert!(!state.param_history_step(true), "New Project kept the old project's undo");
+    }
+
     /// New Project from the palette starts a project. The command named a
     /// label no arm dispatched, so the row ran and nothing happened.
     #[test]
diff --git a/src/param_history.rs b/src/param_history.rs
new file mode 100644
index 0000000..438a6b1
--- /dev/null
+++ b/src/param_history.rs
@@ -0,0 +1,71 @@
+//! Undo for edits to a node's parameters that are made all at once.
+//!
+//! The app has no project-wide history: a text box undoes its own typing
+//! and a viewer state its own handles. This is the third, and it holds one
+//! kind of step — a node's whole parameter list as it stood before a command
+//! rewrote it. Reset Parameters is the first to record here.
+//!
+//! A snapshot is of ONE node, by id. Restoring the whole tree would be less
+//! to write and would take back every edit made anywhere since, none of
+//! which are recorded; restoring one node's parameters takes back what the
+//! command did to that node and leaves the rest of the project alone.
+//!
+//! It is not `cce_ui::history::History` because a step has to be looked at
+//! before it is taken: the snapshot filed for redo is the CURRENT state of
+//! the node the step names, and which node that is is in the step.
+
+use crate::param::ParamDef;
+
+/// A node's parameters as they stood, and what changed them.
+#[derive(Clone)]
+pub struct ParamSnapshot {
+    pub node_id: String,
+    pub params: Vec<ParamDef>,
+    /// What the step was, for the status line: "Reset Parameters".
+    pub what: &'static str,
+}
+
+pub const LIMIT: usize = 64;
+
+#[derive(Default)]
+pub struct ParamHistory {
+    undo: Vec<ParamSnapshot>,
+    redo: Vec<ParamSnapshot>,
+}
+
+impl ParamHistory {
+    /// File `before` as what the next undo returns to. A new edit forks:
+    /// what had been undone cannot be redone over it.
+    pub fn record(&mut self, before: ParamSnapshot) {
+        self.redo.clear();
+        self.undo.push(before);
+        if self.undo.len() > LIMIT {
+            self.undo.remove(0);
+        }
+    }
+
+    /// Take a step off one stack. The caller restores it and files the
+    /// node's state from before the restore with [`Self::file`].
+    pub fn take(&mut self, undo: bool) -> Option<ParamSnapshot> {
+        if undo { self.undo.pop() } else { self.redo.pop() }
+    }
+
+    /// File the state a taken step replaced, on the OTHER stack.
+    pub fn file(&mut self, undo: bool, replaced: ParamSnapshot) {
+        if undo { self.redo.push(replaced) } else { self.undo.push(replaced) }
+    }
+
+    pub fn can_undo(&self) -> bool {
+        !self.undo.is_empty()
+    }
+
+    pub fn can_redo(&self) -> bool {
+        !self.redo.is_empty()
+    }
+
+    /// Another document: its nodes are not these.
+    pub fn clear(&mut self) {
+        self.undo.clear();
+        self.redo.clear();
+    }
+}
diff --git a/src/project.rs b/src/project.rs
index 28bd916..546e84f 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -519,6 +519,7 @@ impl State {
         proj.migrate_param_refs();
         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
         self.fs_root = proj.root;
+        self.param_history.clear();
         self.migrate_meta_settings_node();
         // As in the default-project branch.
         if let Some(d) = &proj.view_state.display {
@@ -644,6 +645,7 @@ impl State {
             inputs: 0,
             outputs: 0,
         };
+        self.param_history.clear();
         self.migrate_meta_settings_node();
         self.set_active_camera("Default Camera");
         self.pan_x = 0.0;