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