graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: adding, deleting, moving and wiring nodes are undoable
The parameter history becomes the edit history, one stack of two kinds
of step. A structure step is recorded by noticing: once an event or an
action has been applied the tree is compared with how it stood at the
last look, and the nodes that came, went, moved, were rewired or had a
flag changed are the step. A removed node is kept whole and comes back
where it was among its siblings; the editors' paths and the selection
are held by id across a step.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 87 ++++++---
src/app.rs | 82 ++------
src/application.rs | 4 +-
src/edit_history.rs | 537 +++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 180 +++++++++++++++--
src/param_history.rs | 128 ------------
src/project.rs | 8 +-
src/window.rs | 3 +
8 files changed, 787 insertions(+), 242 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index a2284dc..2cd414e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -130,40 +130,67 @@ 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.
-**Parameter edits are undoable** (`src/param_history.rs`, the same day),
-the first thing outside a text box or a viewer state that is.
-`State::param_history` holds one kind of step: the parameters of one node
-that CHANGED, as they stood before. Four writers record: the params pane's
-write-back (`sync_parameters_to_project`), the row menu
-(`run_param_action`, whose work is `run_param_action_unrecorded`), MCP's
-`set_param`, and Reset Parameters; the middle two through
-`State::record_param_edit`. What writes a parameter without passing one
-of them is not recorded: a viewer state's handles (which have their own
-history while the state lasts), a camera node under an orbit, View 1:1
-and the image commands, the template merge.
-
-- **By node ID and parameter NAME.** A rename in between does not lose a
- step, and a deleted node drops its step with a line on the status bar.
- A step restores the parameters it names and nothing else — not the
- node's whole list, which would take back a camera's orbit or a curve's
- handles along with a slider, and not the tree.
-- **One step per gesture.** The pane writes back on every motion of a
- drag, so records of one group (the node and the parameters changed) are
- one step until the group is broken: by any press or release, by Enter,
- Tab or Escape (all at the top of `handle_event`), or by
- `GROUP_IDLE` (a second) with nothing recorded, which is what ends a run
- of wheel notches. A write-back that changes nothing records nothing, so
- a button and the Open dropdown, which end as they began, are no edit.
+**Edits to the node tree are undoable** (`src/edit_history.rs`, the same
+day), the first thing outside a text box or a viewer state that is.
+`State::edit_history` is ONE stack of two kinds of step, so that undo takes
+them back in the order they were made:
+
+- **Parameters**: the parameters of one node that CHANGED, as they stood.
+ Recorded by the writers, through `State::record_params` — the params
+ pane's write-back (`sync_parameters_to_project`), the row menu
+ (`run_param_action`, whose work is `run_param_action_unrecorded`), MCP's
+ `set_param`, and Reset Parameters.
+- **Structure**: nodes added, removed and moved, wires made and broken,
+ the display and bypass flags. Recorded by NOTICING:
+ `record_structure_changes` compares the tree with how it stood at the
+ last look (`State::structure_base`) and what differs is the step. It
+ runs at the end of `process_window_event` and of `apply_action`, and
+ ahead of every undo and every parameter record, so nothing done is left
+ unlooked at. There are a dozen writers of the graph — the widget's drag
+ read back by `read_panel_offsets`, the keyboard families, paste, the
+ palette's pick, MCP, the image commands, Arrange — and a recording call
+ in each is one the thirteenth would not make. **A new writer of the
+ graph needs nothing.**
+
+The rules:
+
+- **A step holds what changed and nothing else.** A parameter step names
+ its parameters; a structure step names its nodes, by id, and of a node
+ that stayed only its position, its two flags and its wires (the
+ parameters of the `node` kind). So what is written without being
+ recorded is left as it is by an undo: a camera node's Rotation under an
+ orbit, a curve's Points under its handles (which have their own history
+ while the viewer state lasts), View 1:1, the template merge. A removed
+ node is kept whole, children and all, and comes back at the place it had
+ among its siblings.
+- **What replaces the tree is not an edit.** New Project and Open clear
+ the history and drop the base; the sync channel's reload drops the base
+ and keeps the history. A level whose children cannot be told apart by id
+ (a hand-built tree with empty ids) is not followed.
+- **One step per gesture.** Records that share a group are one step until
+ the group is broken: by any press or release, by Enter, Tab or Escape
+ (all at the top of `handle_event`), or by `GROUP_IDLE` (a second) with
+ nothing recorded. The pane's group is the node and the parameters
+ changed; the graph's is a MOVE of the same nodes, so a run of alt+hjkl is
+ one step. The graph is not looked at while a drag is held
+ (`gesture_held`), so a dragged node is one step from where it was picked
+ up.
+- **Slots are held by id across a structure step**: the two editors'
+ paths and the selection, which a node coming or going would move. An
+ editor inside a node that an undo takes out comes up to where the node
+ was.
- **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.
- A focused text box is ahead of all of them, in the toolkit's runner.
-- **New Project and Open clear it**; the sync channel's reload does not,
- the nodes being the same ones.
+ palette) — so the order ACROSS the three is by owner and not by time. A
+ focused text box is ahead of all of them, in the toolkit's runner.
+- **A rename is not recorded.** It rewrites the expression paths and
+ wires that name the node, anywhere in the tree, and a step that put the
+ name back without them would leave them naming nothing.
- 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.
+ before taking it, and what is filed for redo is the current state of
+ what the step names.
+`the_graph_is_undone_a_step_at_a_time`,
`a_parameter_edit_is_undone_a_gesture_at_a_time` and
`reset_parameters_is_undone_and_redone` are the tests.
diff --git a/src/app.rs b/src/app.rs
index 9b661db..2578330 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2059,10 +2059,13 @@ 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
+ /// Undo for edits to the node tree, parameters and structure — see
+ /// `src/edit_history.rs`. Consulted after a code row and a viewer
/// state have had their turn.
- pub param_history: crate::param_history::ParamHistory,
+ pub edit_history: crate::edit_history::EditHistory,
+ /// The tree as it stood when its structure was last looked at, which
+ /// the next look is compared with. None until the first.
+ pub structure_base: Option<FsNode>,
/// 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,
@@ -3132,18 +3135,18 @@ impl State {
p.set_expr(is_expr);
}
}
- let before = crate::param_history::ParamSnapshot {
+ let before = crate::edit_history::ParamSnapshot {
node_id: node.id.clone(),
params: was
.into_iter()
.zip(node.params.iter())
- .filter(|(a, b)| !crate::param_history::same(a, b))
+ .filter(|(a, b)| !crate::edit_history::same(a, b))
.map(|(a, _)| a)
.collect(),
what: "Reset Parameters".to_string(),
};
if !before.params.is_empty() {
- self.param_history.record(before);
+ self.record_params(before, false);
}
self.sync_nodes();
self.rebuild_scene_geometry();
@@ -3152,54 +3155,6 @@ 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;
- };
- // By name: the step holds the parameters that changed, and the rest
- // of the node is as whatever wrote it last left it.
- let mut replaced = Vec::new();
- for was in step.params {
- if let Some(p) = node.params.iter_mut().find(|p| p.name == was.name) {
- replaced.push(std::mem::replace(p, was));
- }
- }
- let name = node.name.clone();
- let what = step.what.clone();
- self.param_history.file(
- undo,
- crate::param_history::ParamSnapshot { node_id: step.node_id, params: replaced, what: step.what },
- );
- self.sync_grid_settings();
- self.sync_nodes();
- self.rebuild_scene_geometry();
- self.sync_parameters_pane();
- self.update_status_text(&format!("{verb} {what}: {name}"));
- true
- }
-
- /// Record that `pname` of a node was `before` until just now, if it is
- /// not still. For the writers that change one parameter in one go: the
- /// row menu and `set_param`.
- pub fn record_param_edit(&mut self, node_id: &str, before: ParamDef) {
- let now = crate::viewer_state::find_node_by_id(&self.fs_root, node_id)
- .and_then(|n| n.params.iter().find(|p| p.name == before.name));
- if now.is_some_and(|p| !crate::param_history::same(p, &before)) {
- self.param_history.record(crate::param_history::ParamSnapshot {
- node_id: node_id.to_string(),
- what: before.name.clone(),
- params: vec![before],
- });
- }
- }
-
pub fn cursor_in_viewport(&self) -> bool {
if self.network_overlay() {
// The complement of the overlay: everything in the body the
@@ -3812,15 +3767,15 @@ impl State {
// motion. A button or the Open dropdown ends as it
// began, and is no edit.
was.retain(|w| {
- child.params.iter().find(|p| p.name == w.name).is_some_and(|p| !crate::param_history::same(p, w))
+ child.params.iter().find(|p| p.name == w.name).is_some_and(|p| !crate::edit_history::same(p, w))
});
- let edit = (!was.is_empty()).then(|| crate::param_history::ParamSnapshot {
+ let edit = (!was.is_empty()).then(|| crate::edit_history::ParamSnapshot {
node_id: child.id.clone(),
what: was.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", "),
params: was,
});
if let Some(edit) = edit {
- self.param_history.record_grouped(edit);
+ self.record_params(edit, true);
}
if !triggered_buttons.is_empty() || !display_resets.is_empty() || !rejected.is_empty() {
@@ -6741,7 +6696,8 @@ 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(),
+ edit_history: Default::default(),
+ structure_base: None,
network_menu_active: false,
network_menu_actions: Vec::new(),
sim_cache: crate::geometry::SimCache::default(),
@@ -8504,13 +8460,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// chords arrive through `Application::undo` / `redo` (the
// toolkit routes them, after the focused text box's turn); the
// Edit menu rows come here directly. A viewer state's history
- // first, then the parameter history (`src/param_history.rs`);
+ // first, then the edit history (`src/edit_history.rs`);
// a project-wide one would be consulted after both decline.
Action::Undo => {
- let _ = self.viewer_tool_undo() || self.param_history_step(true);
+ let _ = self.viewer_tool_undo() || self.history_step(true);
}
Action::Redo => {
- let _ = self.viewer_tool_redo() || self.param_history_step(false);
+ let _ = self.viewer_tool_redo() || self.history_step(false);
}
Action::ToggleGrid => {
let val = !self.viewport().show_grid;
@@ -8907,13 +8863,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// A press, a release, or a key that ends an entry ends the gesture
// an undo step is: what the params pane writes next is a new one.
match event {
- WindowEvent::MouseInput { .. } => self.param_history.break_group(),
+ WindowEvent::MouseInput { .. } => self.edit_history.break_group(),
WindowEvent::KeyboardInput { event } if event.state == ElementState::Pressed => {
if matches!(
event.logical_key,
Key::Named(NamedKey::Enter | NamedKey::Tab | NamedKey::Escape)
) {
- self.param_history.break_group();
+ self.edit_history.break_group();
}
}
_ => {}
diff --git a/src/application.rs b/src/application.rs
index ba194fe..59f1d81 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -328,7 +328,7 @@ impl Application for State {
// 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()
- || self.param_history_step(true);
+ || self.history_step(true);
if taken {
*needs_rebuild = true;
}
@@ -338,7 +338,7 @@ 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()
- || self.param_history_step(false);
+ || self.history_step(false);
if taken {
*needs_rebuild = true;
}
diff --git a/src/edit_history.rs b/src/edit_history.rs
new file mode 100644
index 0000000..420a945
--- /dev/null
+++ b/src/edit_history.rs
@@ -0,0 +1,537 @@
+//! Undo for edits to the node tree: parameters, and the graph itself.
+//!
+//! 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. It holds two
+//! kinds of step, on ONE stack so that undo takes them back in the order
+//! they were made:
+//!
+//! - **Parameters** — parameters of one node as they stood before
+//! something changed them: a row of the params pane, the row menu,
+//! `set_param`, or a command that rewrites the lot (Reset Parameters).
+//! Recorded by the writers.
+//! - **Structure** — nodes added, nodes removed, nodes moved, wires made
+//! and broken, the display and bypass flags. Recorded by NOTICING: the
+//! tree is compared with how it stood at the last look
+//! (`State::structure_base`) once an event or an action has been
+//! applied, and what differs is the step. There are a dozen writers of
+//! the graph — the widget's own drag read back a frame later, the
+//! keyboard families, paste, the palette, MCP, the image commands,
+//! Arrange — and a recording call in each is one that the thirteenth
+//! would not make.
+//!
+//! **A step holds what changed and nothing else.** A parameter step names
+//! its parameters; a structure step names its nodes, and of a node that
+//! stayed only its position, its two flags and its wires. Restoring a
+//! node's whole parameter list would take back what is written to it
+//! without passing here — a camera node's Rotation under an orbit, a
+//! curve's Points under its handles.
+//!
+//! **One step per gesture.** The pane writes back on every motion of a
+//! drag, so records that share a group are one step until the group is
+//! broken: by a press or a release, by Enter, Tab or Escape, or by
+//! [`GROUP_IDLE`] without a record, which is what ends a run of wheel
+//! notches or of alt+hjkl. The graph is not looked at while a drag is
+//! held, so a dragged node is one step, from where it was picked up.
+//!
+//! It is not `cce_ui::history::History` because a step has to be looked at
+//! before it is taken: what is filed for redo is the CURRENT state of what
+//! the step names, and what that is is in the step.
+
+use crate::app::{FsNode, State};
+use cce_ui::widget::GraphController as _;
+use crate::param::{ParamDef, ParamKind};
+use std::time::{Duration, Instant};
+
+/// Parameters of a node 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",
+ /// "Radius".
+ pub what: String,
+}
+
+impl ParamSnapshot {
+ /// The group an edit of these parameters belongs to.
+ pub fn group(&self) -> String {
+ let names: Vec<&str> = self.params.iter().map(|p| p.name.as_str()).collect();
+ format!("{}\u{0}{}", self.node_id, names.join("\u{0}"))
+ }
+}
+
+/// Whether two states of a parameter are one: what a step would restore.
+pub fn same(a: &ParamDef, b: &ParamDef) -> bool {
+ a.text() == b.text() && a.is_expr() == b.is_expr() && a.view == b.view
+}
+
+pub const LIMIT: usize = 256;
+/// How long a group stands with nothing recorded into it.
+pub const GROUP_IDLE: Duration = Duration::from_millis(1000);
+
+/// What a node of a level was, before a structure step.
+#[derive(Clone)]
+pub enum NodeBefore {
+ /// Not there: the step added it.
+ Absent { id: String },
+ /// There, whole, at this place among its siblings: the step removed it.
+ Whole { index: usize, node: FsNode },
+ /// There, and these of its fields were otherwise. `wires` holds the
+ /// wire parameters that differed, as they were.
+ Fields { id: String, position: (f32, f32), geometry_visible: bool, bypassed: bool, wires: Vec<ParamDef> },
+}
+
+/// The nodes of one level a structure step changed. `dir_id` is the id of
+/// the node whose children they are.
+#[derive(Clone)]
+pub struct LevelBefore {
+ pub dir_id: String,
+ pub nodes: Vec<NodeBefore>,
+}
+
+#[derive(Clone)]
+pub struct StructureStep {
+ pub levels: Vec<LevelBefore>,
+ pub what: String,
+}
+
+#[derive(Clone)]
+pub enum Step {
+ Params(ParamSnapshot),
+ Structure(StructureStep),
+}
+
+impl Step {
+ pub fn what(&self) -> &str {
+ match self {
+ Step::Params(p) => &p.what,
+ Step::Structure(s) => &s.what,
+ }
+ }
+}
+
+#[derive(Default)]
+pub struct EditHistory {
+ undo: Vec<Step>,
+ redo: Vec<Step>,
+ /// The group of the last record and when it was made.
+ group: Option<(String, Instant)>,
+}
+
+impl EditHistory {
+ /// 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: Step) {
+ self.group = None;
+ self.push(before);
+ }
+
+ /// [`Self::record`], unless the last record was of the same group and
+ /// the group still stands: the earlier step already holds what this
+ /// gesture began from.
+ pub fn record_grouped(&mut self, before: Step, group: String) {
+ let now = Instant::now();
+ let standing = matches!(&self.group, Some((g, at)) if *g == group && now.duration_since(*at) < GROUP_IDLE);
+ if !(standing && !self.undo.is_empty()) {
+ self.push(before);
+ }
+ self.group = Some((group, now));
+ }
+
+ /// The gesture is over: the next grouped record is a step of its own.
+ pub fn break_group(&mut self) {
+ self.group = None;
+ }
+
+ fn push(&mut self, before: Step) {
+ 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 what
+ /// the restore replaced with [`Self::file`].
+ pub fn take(&mut self, undo: bool) -> Option<Step> {
+ self.group = None;
+ 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: Step) {
+ 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()
+ }
+
+ pub fn undo_len(&self) -> usize {
+ self.undo.len()
+ }
+
+ /// Another document: its nodes are not these.
+ pub fn clear(&mut self) {
+ self.undo.clear();
+ self.redo.clear();
+ self.group = None;
+ }
+}
+
+/// A node's wires: its parameters of the `node` kind.
+fn wires(node: &FsNode) -> impl Iterator<Item = &ParamDef> {
+ node.params.iter().filter(|p| p.kind() == ParamKind::Node)
+}
+
+/// Whether a level's children can be told apart by id. A hand-built tree
+/// may carry empty ids or one id twice; such a level is not followed.
+fn ids_tell_apart(nodes: &[FsNode]) -> bool {
+ let mut seen = std::collections::HashSet::new();
+ nodes.iter().all(|n| !n.id.is_empty() && seen.insert(n.id.as_str()))
+}
+
+/// What differs between how a tree stood and how it stands.
+#[derive(Default)]
+pub struct Difference {
+ /// The structure that changed, as it WAS: a step's content.
+ pub levels: Vec<LevelBefore>,
+ /// Whether anything differs at all, a parameter's value included —
+ /// which is no step, and is when the base has to be taken again.
+ pub any: bool,
+ added: Vec<String>,
+ removed: Vec<String>,
+ moved: Vec<String>,
+ wired: usize,
+ flagged: usize,
+}
+
+impl Difference {
+ /// The step's name, for the status line.
+ pub fn what(&self) -> String {
+ let list = |names: &[String]| match names {
+ [one] => one.clone(),
+ many => format!("{} nodes", many.len()),
+ };
+ if !self.added.is_empty() && self.removed.is_empty() {
+ format!("Add {}", list(&self.added))
+ } else if !self.removed.is_empty() && self.added.is_empty() {
+ format!("Delete {}", list(&self.removed))
+ } else if !self.added.is_empty() {
+ "Edit Nodes".to_string()
+ } else if self.wired > 0 {
+ "Wire".to_string()
+ } else if !self.moved.is_empty() {
+ format!("Move {}", list(&self.moved))
+ } else {
+ "Node Flag".to_string()
+ }
+ }
+
+ /// The group a run of these belongs to, when it is a move and nothing
+ /// else: a run of alt+hjkl is one step.
+ pub fn move_group(&self) -> Option<String> {
+ let only_moves = self.added.is_empty() && self.removed.is_empty() && self.wired == 0 && self.flagged == 0;
+ (only_moves && !self.moved.is_empty()).then(|| format!("move\u{0}{}", self.moved.join("\u{0}")))
+ }
+}
+
+/// Compare a level, and the levels under the nodes that stayed.
+pub fn difference(base: &FsNode, now: &FsNode, out: &mut Difference) {
+ if !ids_tell_apart(&base.children) || !ids_tell_apart(&now.children) {
+ out.any |= base.children.len() != now.children.len();
+ return;
+ }
+ let mut nodes = Vec::new();
+ for (index, was) in base.children.iter().enumerate() {
+ let Some(is) = now.children.iter().find(|n| n.id == was.id) else {
+ out.removed.push(was.name.clone());
+ nodes.push(NodeBefore::Whole { index, node: was.clone() });
+ continue;
+ };
+ let changed_wires: Vec<ParamDef> = wires(was)
+ .filter(|w| is.params.iter().find(|p| p.name == w.name).is_some_and(|p| !same(p, w)))
+ .cloned()
+ .collect();
+ let moved = was.position != is.position;
+ let flagged = was.geometry_visible != is.geometry_visible || was.bypassed != is.bypassed;
+ if moved || flagged || !changed_wires.is_empty() {
+ if moved {
+ out.moved.push(is.name.clone());
+ }
+ out.flagged += flagged as usize;
+ out.wired += changed_wires.len();
+ nodes.push(NodeBefore::Fields {
+ id: was.id.clone(),
+ position: was.position,
+ geometry_visible: was.geometry_visible,
+ bypassed: was.bypassed,
+ wires: changed_wires,
+ });
+ }
+ out.any |= was.name != is.name
+ || was.params.len() != is.params.len()
+ || was.params.iter().zip(is.params.iter()).any(|(a, b)| a.name != b.name || !same(a, b));
+ difference(was, is, out);
+ }
+ for is in &now.children {
+ if !base.children.iter().any(|n| n.id == is.id) {
+ out.added.push(is.name.clone());
+ nodes.push(NodeBefore::Absent { id: is.id.clone() });
+ }
+ }
+ if !nodes.is_empty() {
+ out.any = true;
+ out.levels.push(LevelBefore { dir_id: base.id.clone(), nodes });
+ }
+}
+
+/// Put a tree back as a step says it was, and return the step that would
+/// put it back as it is: undo's is redo's and redo's undo's.
+pub fn restore(root: &mut FsNode, step: StructureStep) -> StructureStep {
+ let mut levels = Vec::new();
+ for level in step.levels {
+ let Some(dir) = crate::viewer_state::find_node_by_id_mut(root, &level.dir_id) else { continue };
+ let mut nodes: Vec<NodeBefore> = Vec::new();
+ let mut going: Vec<String> = Vec::new();
+ let mut back: Vec<(usize, FsNode)> = Vec::new();
+ for node in level.nodes {
+ match node {
+ NodeBefore::Absent { id } => going.push(id),
+ NodeBefore::Whole { index, node } => back.push((index, node)),
+ NodeBefore::Fields { id, position, geometry_visible, bypassed, wires } => {
+ let Some(n) = dir.children.iter_mut().find(|n| n.id == id) else { continue };
+ let mut were = Vec::new();
+ for w in wires {
+ if let Some(p) = n.params.iter_mut().find(|p| p.name == w.name) {
+ were.push(std::mem::replace(p, w));
+ }
+ }
+ nodes.push(NodeBefore::Fields {
+ id,
+ position: std::mem::replace(&mut n.position, position),
+ geometry_visible: std::mem::replace(&mut n.geometry_visible, geometry_visible),
+ bypassed: std::mem::replace(&mut n.bypassed, bypassed),
+ wires: were,
+ });
+ }
+ }
+ }
+ // What the step added goes, as it stands NOW and from where it
+ // stands — the places read before any of them is taken out.
+ let mut gone: Vec<(usize, FsNode)> = dir
+ .children
+ .iter()
+ .enumerate()
+ .filter(|(_, n)| going.contains(&n.id))
+ .map(|(i, n)| (i, n.clone()))
+ .collect();
+ dir.children.retain(|n| !going.contains(&n.id));
+ nodes.extend(gone.drain(..).map(|(index, node)| NodeBefore::Whole { index, node }));
+ // What it removed comes back where it was, lowest first, so each
+ // lands among the siblings it had.
+ back.sort_by_key(|(index, _)| *index);
+ for (index, node) in back {
+ nodes.push(NodeBefore::Absent { id: node.id.clone() });
+ let at = index.min(dir.children.len());
+ dir.children.insert(at, node);
+ }
+ levels.push(LevelBefore { dir_id: level.dir_id, nodes });
+ }
+ StructureStep { levels, what: step.what }
+}
+
+impl State {
+ /// Take the tree as it stands for what the next look compares with.
+ /// After anything that replaces the tree or that has recorded its own
+ /// step, so that it is not noticed as an edit.
+ pub fn rebase_structure(&mut self) {
+ self.structure_base = Some(self.fs_root.clone());
+ }
+
+ /// Whether a drag is held. The graph is not looked at until it is let
+ /// go: a dragged node is one step, from where it was picked up.
+ fn gesture_held(&self) -> bool {
+ self.drag_widget.is_some() || self.app_drag.is_some() || self.node_drag_group.is_some()
+ }
+
+ /// Look at the tree, and record what of its structure has changed
+ /// since the last look. Run once an event or an action has been
+ /// applied.
+ pub fn record_structure_changes(&mut self) {
+ if !self.gesture_held() {
+ self.note_structure_changes();
+ }
+ }
+
+ fn note_structure_changes(&mut self) {
+ let Some(base) = &self.structure_base else {
+ self.rebase_structure();
+ return;
+ };
+ let mut diff = Difference::default();
+ difference(base, &self.fs_root, &mut diff);
+ if !diff.any {
+ return;
+ }
+ if !diff.levels.is_empty() {
+ let group = diff.move_group();
+ let step = Step::Structure(StructureStep { what: diff.what(), levels: diff.levels });
+ match group {
+ Some(group) => self.edit_history.record_grouped(step, group),
+ None => self.edit_history.record(step),
+ }
+ }
+ self.rebase_structure();
+ }
+
+ /// Record parameters of a node as they were. For the writers of
+ /// parameters, which know what they changed, and call this once they
+ /// have changed it.
+ pub fn record_params(&mut self, before: ParamSnapshot, grouped: bool) {
+ // A wire is structure too. The base is told of these parameters
+ // as they are now, so that the look below does not take this edit
+ // for one of its own and record it a second time.
+ let now: Vec<ParamDef> = crate::viewer_state::find_node_by_id(&self.fs_root, &before.node_id)
+ .map(|n| {
+ n.params.iter().filter(|p| before.params.iter().any(|b| b.name == p.name)).cloned().collect()
+ })
+ .unwrap_or_default();
+ if let Some(node) = self
+ .structure_base
+ .as_mut()
+ .and_then(|base| crate::viewer_state::find_node_by_id_mut(base, &before.node_id))
+ {
+ for p in now {
+ if let Some(was) = node.params.iter_mut().find(|w| w.name == p.name) {
+ *was = p;
+ }
+ }
+ }
+ // What the graph did before this edit is its own step, under it.
+ self.note_structure_changes();
+ if grouped {
+ let group = before.group();
+ self.edit_history.record_grouped(Step::Params(before), group);
+ } else {
+ self.edit_history.record(Step::Params(before));
+ }
+ }
+
+ /// Record that `pname` of a node was `before` until just now, if it is
+ /// not still. For the writers that change one parameter in one go: the
+ /// row menu and `set_param`.
+ pub fn record_param_edit(&mut self, node_id: &str, before: ParamDef) {
+ let now = crate::viewer_state::find_node_by_id(&self.fs_root, node_id)
+ .and_then(|n| n.params.iter().find(|p| p.name == before.name));
+ if now.is_some_and(|p| !same(p, &before)) {
+ self.record_params(
+ ParamSnapshot { node_id: node_id.to_string(), what: before.name.clone(), params: vec![before] },
+ false,
+ );
+ }
+ }
+
+ /// Undo (or redo) the last edit to the tree. False when there is none,
+ /// so the caller can say nothing was taken.
+ pub fn history_step(&mut self, undo: bool) -> bool {
+ // What has been done and not yet looked at is the step to take.
+ self.note_structure_changes();
+ let Some(step) = self.edit_history.take(undo) else { return false };
+ let verb = if undo { "Undo" } else { "Redo" };
+ let what = step.what().to_string();
+ match step {
+ Step::Params(step) => {
+ 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} {what}: the node is gone"));
+ return false;
+ };
+ // By name: the step holds the parameters that changed, and
+ // the rest of the node is as whatever wrote it last left it.
+ let mut replaced = Vec::new();
+ for was in step.params {
+ if let Some(p) = node.params.iter_mut().find(|p| p.name == was.name) {
+ replaced.push(std::mem::replace(p, was));
+ }
+ }
+ let name = node.name.clone();
+ self.edit_history.file(
+ undo,
+ Step::Params(ParamSnapshot { node_id: step.node_id, params: replaced, what: step.what }),
+ );
+ self.sync_grid_settings();
+ self.sync_nodes();
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ self.update_status_text(&format!("{verb} {what}: {name}"));
+ }
+ Step::Structure(step) => {
+ // Where the editors are and what is selected are slots,
+ // which a node coming or going moves: held by id across it.
+ let path = self.ids_along(&self.current_path.clone());
+ let path2 = self.ids_along(&self.current_path2.clone());
+ let selected = self
+ .graph()
+ .selected_node()
+ .and_then(|i| self.current_dir().children.get(i))
+ .map(|n| n.id.clone());
+ let inverse = restore(&mut self.fs_root, step);
+ self.edit_history.file(undo, Step::Structure(inverse));
+ self.current_path = self.slots_along(&path);
+ self.current_path2 = self.slots_along(&path2);
+ let slot = selected.and_then(|id| self.current_dir().children.iter().position(|n| n.id == id));
+ self.graph_mut().set_selected_node(slot);
+ self.slots.content2.set_selected_node(None);
+ self.grid_cursor_expanse = None;
+ let at = slot.and_then(|i| self.current_dir().children.get(i)).map(|n| n.position);
+ if let Some((col, row)) = at {
+ self.grid_cursor_col = col as i32;
+ self.grid_cursor_row = row as i32;
+ }
+ self.sync_nodes();
+ self.rebuild_positions();
+ self.apply_layout();
+ self.update_panel_bounds();
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ self.viewport_dirty = true;
+ self.update_status_text(&format!("{verb} {what}"));
+ }
+ }
+ self.rebase_structure();
+ if self.syncing_windows() {
+ self.needs_autosave = true;
+ }
+ true
+ }
+
+ /// The ids of the nodes a path of slots goes down through.
+ fn ids_along(&self, path: &[usize]) -> Vec<String> {
+ let mut node = &self.fs_root;
+ let mut ids = Vec::new();
+ for &slot in path {
+ let Some(child) = node.children.get(slot) else { break };
+ ids.push(child.id.clone());
+ node = child;
+ }
+ ids
+ }
+
+ /// The path of slots that goes down through those nodes now, as far as
+ /// they are still there.
+ fn slots_along(&self, ids: &[String]) -> Vec<usize> {
+ let mut node = &self.fs_root;
+ let mut path = Vec::new();
+ for id in ids {
+ let Some(slot) = node.children.iter().position(|n| n.id == *id) else { break };
+ path.push(slot);
+ node = &node.children[slot];
+ }
+ path
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index d31b1bf..5c4e7d8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,7 +1,7 @@
pub mod app;
pub mod param;
-pub mod param_history;
+pub mod edit_history;
pub mod application;
pub mod curve_tool;
pub mod soft_transform_tool;
@@ -6742,7 +6742,7 @@ mod tests {
}
let edited = texts(&state);
- assert!(!state.param_history_step(true), "nothing to undo before the reset");
+ assert!(!state.history_step(true), "nothing to undo before the reset");
assert!(state.run_command("reset_parameters"));
let reset = texts(&state);
assert_ne!(reset, edited);
@@ -6758,20 +6758,24 @@ mod tests {
// 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!(state.history_step(true));
assert_eq!(texts(&state), edited);
- // A node that is gone takes its step with it, and says so.
+ // A node deleted since comes back first, and then its parameters.
state.run_command("reset_parameters");
- state.current_dir_mut().children.remove(slot);
- assert!(!state.param_history_step(true));
+ let reset = texts(&state);
+ state.delete_node(slot);
+ assert!(state.history_step(true), "the delete is the last step");
+ assert_eq!(texts(&state), reset, "the node came back as it was deleted");
+ assert!(state.history_step(true));
+ assert_eq!(texts(&state), edited);
// 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");
+ assert!(!state.history_step(true), "New Project kept the old project's undo");
}
/// An edit to a parameter can be taken back however it was made: a row
@@ -6812,19 +6816,19 @@ mod tests {
for v in ["1.10", "1.20", "1.30"] {
pane(&mut state, "Radius", v);
}
- assert_eq!(state.param_history.undo_len(), 1, "a drag is one step");
+ assert_eq!(state.edit_history.undo_len(), 1, "a drag is one step");
// A write-back that changes nothing records nothing.
state.sync_parameters_to_project();
- assert_eq!(state.param_history.undo_len(), 1);
+ assert_eq!(state.edit_history.undo_len(), 1);
// A release and a press, then a second drag of the same row.
- state.param_history.break_group();
+ state.edit_history.break_group();
for v in ["1.40", "1.50"] {
pane(&mut state, "Radius", v);
}
- assert_eq!(state.param_history.undo_len(), 2, "a second drag is a second step");
+ assert_eq!(state.edit_history.undo_len(), 2, "a second drag is a second step");
// Another row, with no press between: its own step all the same.
pane(&mut state, "Rows", "9");
- assert_eq!(state.param_history.undo_len(), 3);
+ assert_eq!(state.edit_history.undo_len(), 3);
assert!(state.run_command("undo"));
assert_eq!(param(&state, "Rows"), rows);
@@ -6833,7 +6837,7 @@ mod tests {
assert_eq!(param(&state, "Radius").0, "1.30");
assert!(state.run_command("undo"));
assert_eq!(param(&state, "Radius"), radius);
- assert!(!state.param_history_step(true), "three steps were recorded");
+ assert!(!state.history_step(true), "three steps were recorded");
let shown = state.param().node_params().into_iter().find(|r| r.0 == "Radius").unwrap().1;
assert_eq!(shown, radius.0, "the pane shows the restored value");
for want in ["1.30", "1.50"] {
@@ -6842,9 +6846,9 @@ mod tests {
}
// An edit after an undo forks: what was undone is not redone over it.
assert!(state.run_command("undo"));
- state.param_history.break_group();
+ state.edit_history.break_group();
pane(&mut state, "Radius", "2.00");
- assert!(!state.param_history_step(false), "a new edit left the redo branch standing");
+ assert!(!state.history_step(false), "a new edit left the redo branch standing");
assert!(state.run_command("undo"));
assert_eq!(param(&state, "Radius").0, "1.30");
@@ -6856,10 +6860,10 @@ mod tests {
assert_eq!(param(&state, "Rows").0, "21", "undoing Radius took back an edit to Rows");
// set_param, and one that is refused.
- let before = state.param_history.undo_len();
+ let before = state.edit_history.undo_len();
state.apply_action(McpAction::SetParam { slot, name: "Radius".into(), value: "3.00".into() }, &mut redraw).unwrap();
assert!(state.apply_action(McpAction::SetParam { slot, name: "Radius".into(), value: "abc".into() }, &mut redraw).is_err());
- assert_eq!(state.param_history.undo_len(), before + 1, "a refused value recorded a step");
+ assert_eq!(state.edit_history.undo_len(), before + 1, "a refused value recorded a step");
assert!(state.run_command("undo"));
assert_eq!(param(&state, "Radius").0, "1.30");
@@ -6871,6 +6875,148 @@ mod tests {
assert_eq!(param(&state, "Radius"), ("1.30".to_string(), false), "Copy Parameter is no edit, and Edit Expression is one");
}
+ /// Adding, deleting, moving and wiring nodes can be taken back, in the
+ /// order they were done, among the parameter edits made between them.
+ #[test]
+ fn the_graph_is_undone_a_step_at_a_time() {
+ use crate::app::McpAction;
+ let mut state = State::new(false);
+ let mut redraw = false;
+ // The first look takes the tree as it stands and records nothing.
+ state.record_structure_changes();
+ assert_eq!(state.edit_history.undo_len(), 0);
+ let shape = |state: &State| -> Vec<(String, (f32, f32), String, bool, bool)> {
+ state
+ .current_dir()
+ .children
+ .iter()
+ .map(|c| {
+ let input = c.params.iter().find(|p| p.name == "Input").map(|p| p.text().to_string()).unwrap_or_default();
+ (c.name.clone(), c.position, input, c.geometry_visible, c.bypassed)
+ })
+ .collect()
+ };
+ let start = shape(&state);
+
+ // Add, through MCP as the palette's pick adds.
+ state
+ .apply_action(McpAction::AddNode { template_name: "Box".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw)
+ .unwrap();
+ let added = shape(&state);
+ assert_eq!(added.len(), start.len() + 1);
+ assert_eq!(state.edit_history.undo_len(), 1);
+ let slot = added.len() - 1;
+ let name = added[slot].0.clone();
+
+ // Wire it to the sphere, by the parameter, and move it.
+ state.apply_action(McpAction::SetParam { slot, name: "Radius".into(), value: "1".into() }, &mut redraw).ok();
+ let steps = state.edit_history.undo_len();
+ state.current_dir_mut().children[slot].position = (12.0, 9.0);
+ state.record_structure_changes();
+ assert_eq!(state.edit_history.undo_len(), steps + 1, "a move is a step");
+ let moved = shape(&state);
+ // A second move of the same node straight after is the same step.
+ state.current_dir_mut().children[slot].position = (13.0, 9.0);
+ state.record_structure_changes();
+ assert_eq!(state.edit_history.undo_len(), steps + 1, "a run of moves is one step");
+ // A press between them, and it is another.
+ state.edit_history.break_group();
+ state.current_dir_mut().children[slot].position = (14.0, 9.0);
+ state.record_structure_changes();
+ assert_eq!(state.edit_history.undo_len(), steps + 2);
+ let moved_again = shape(&state);
+
+ // Flags: bypass, through its command's writer.
+ state.set_bypassed(&[slot], true);
+ state.record_structure_changes();
+ let bypassed = shape(&state);
+ assert!(bypassed[slot].4);
+
+ // Delete the first node of the level, which moves every slot.
+ let first = state.current_dir().children[0].clone();
+ state.delete_node(0);
+ state.record_structure_changes();
+ let deleted = shape(&state);
+ assert_eq!(deleted.len(), added.len() - 1);
+
+ // Back, a step at a time.
+ assert!(state.run_command("undo"));
+ assert_eq!(shape(&state), bypassed, "the deleted node came back where it was");
+ assert_eq!(state.current_dir().children[0].id, first.id);
+ assert_eq!(state.current_dir().children[0].params.len(), first.params.len());
+ assert!(state.last_status_text.contains("Undo Delete"), "{}", state.last_status_text);
+ assert!(state.run_command("undo"));
+ assert_eq!(shape(&state), moved_again);
+ assert!(state.run_command("undo"));
+ assert_eq!(shape(&state)[slot].1, (13.0, 9.0), "the run of two moves is where the third began");
+ assert!(state.run_command("undo"));
+ assert_eq!(shape(&state)[slot].1, (9.0, 9.0));
+ let _ = moved;
+ while state.edit_history.undo_len() > 1 {
+ assert!(state.run_command("undo"));
+ }
+ assert_eq!(shape(&state), added);
+ assert!(state.run_command("undo"));
+ assert_eq!(shape(&state), start, "undoing the add took the node out");
+ assert!(!state.history_step(true));
+
+ // And forward again, to the end.
+ while state.history_step(false) {}
+ assert_eq!(shape(&state), deleted);
+ assert!(!state.current_dir().children.iter().any(|c| c.id == first.id));
+ assert!(state.current_dir().children.iter().any(|c| c.name == name));
+
+ // Undoing the add of a node the editor has gone into comes out of it.
+ let mut state = State::new(false);
+ state.record_structure_changes();
+ state
+ .apply_action(McpAction::AddNode { template_name: "Embryo".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw)
+ .unwrap();
+ let slot = state.current_dir().children.len() - 1;
+ state.current_path = vec![slot];
+ state.sync_nodes();
+ assert!(state.history_step(true));
+ assert!(state.current_path.is_empty(), "the editor is inside a node that is gone");
+
+ // A wire, made as the graph makes one and as the pane does: each is
+ // one step, and the second is not noticed a second time.
+ let mut state = State::new(false);
+ state
+ .apply_action(McpAction::AddNode { template_name: "Normal".into(), name: None, x: 9.0, y: 9.0 }, &mut redraw)
+ .unwrap();
+ state.edit_history.clear();
+ let wired = state
+ .current_dir()
+ .children
+ .iter()
+ .position(|c| c.params.iter().any(|p| p.name == "Input" && p.kind() == crate::app::ParamKind::Node))
+ .expect("a Normal node has an Input");
+ let input = |state: &State| {
+ state.current_dir().children[wired].params.iter().find(|p| p.name == "Input").unwrap().text().to_string()
+ };
+ let was = input(&state);
+ state.current_dir_mut().children[wired].params.iter_mut().find(|p| p.name == "Input").unwrap().set_text("camera1".to_string());
+ state.record_structure_changes();
+ assert_eq!(state.edit_history.undo_len(), 1);
+ state
+ .apply_action(McpAction::SetParam { slot: wired, name: "Input".into(), value: String::new() }, &mut redraw)
+ .unwrap();
+ assert_eq!(state.edit_history.undo_len(), 2, "a wire set as a parameter is one step, not two");
+ assert!(state.run_command("undo"));
+ assert_eq!(input(&state), "camera1");
+ assert!(state.run_command("undo"));
+ assert_eq!(input(&state), was);
+ assert!(state.last_status_text.contains("Wire"), "{}", state.last_status_text);
+
+ // A project opened is not an edit, and takes the history with it.
+ state
+ .apply_action(McpAction::AddNode { template_name: "Box".into(), name: None, x: 3.0, y: 9.0 }, &mut redraw)
+ .unwrap();
+ state.new_project();
+ state.record_structure_changes();
+ assert_eq!(state.edit_history.undo_len(), 0, "New Project was recorded as an edit");
+ }
+
/// 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
deleted file mode 100644
index 9713d8f..0000000
--- a/src/param_history.rs
+++ /dev/null
@@ -1,128 +0,0 @@
-//! Undo for edits to a node's parameters.
-//!
-//! 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 — parameters of one node as they stood before something
-//! changed them: a row of the params pane, the row menu, `set_param`, or a
-//! command that rewrites the lot (Reset Parameters).
-//!
-//! A step holds the parameters that CHANGED, by name, of ONE node, by id.
-//! Restoring the whole tree would be less to write and would take back
-//! every edit made anywhere since; restoring a node's whole list would take
-//! back what is written to it without passing here — a camera node's
-//! Rotation under an orbit, a curve's Points under its handles. A step puts
-//! back what it replaced and nothing else.
-//!
-//! **One step per gesture.** The pane writes back on every motion of a
-//! drag, so records that share a group — the node and the parameters
-//! changed — are one step until the group is broken: by a press or a
-//! release, by Enter, Tab or Escape, or by [`GROUP_IDLE`] without a record,
-//! which is what ends a run of wheel notches.
-//!
-//! 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;
-use std::time::{Duration, Instant};
-
-/// Parameters of a node 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",
- /// "Radius".
- pub what: String,
-}
-
-impl ParamSnapshot {
- /// The group an edit of these parameters belongs to.
- pub fn group(&self) -> String {
- let names: Vec<&str> = self.params.iter().map(|p| p.name.as_str()).collect();
- format!("{}\u{0}{}", self.node_id, names.join("\u{0}"))
- }
-}
-
-/// Whether two states of a parameter are one: what a step would restore.
-pub fn same(a: &ParamDef, b: &ParamDef) -> bool {
- a.text() == b.text() && a.is_expr() == b.is_expr() && a.view == b.view
-}
-
-pub const LIMIT: usize = 256;
-/// How long a group stands with nothing recorded into it.
-pub const GROUP_IDLE: Duration = Duration::from_millis(1000);
-
-#[derive(Default)]
-pub struct ParamHistory {
- undo: Vec<ParamSnapshot>,
- redo: Vec<ParamSnapshot>,
- /// The group of the last record and when it was made.
- group: Option<(String, Instant)>,
-}
-
-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.group = None;
- self.push(before);
- }
-
- /// [`Self::record`], unless the last record was of the same group and
- /// the group still stands: the earlier snapshot already holds what this
- /// gesture began from.
- pub fn record_grouped(&mut self, before: ParamSnapshot) {
- let group = before.group();
- let now = Instant::now();
- let standing = matches!(&self.group, Some((g, at)) if *g == group && now.duration_since(*at) < GROUP_IDLE);
- if !(standing && !self.undo.is_empty()) {
- self.push(before);
- }
- self.group = Some((group, now));
- }
-
- /// The gesture is over: the next grouped record is a step of its own.
- pub fn break_group(&mut self) {
- self.group = None;
- }
-
- fn push(&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 what
- /// the restore replaced with [`Self::file`].
- pub fn take(&mut self, undo: bool) -> Option<ParamSnapshot> {
- self.group = None;
- 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()
- }
-
- pub fn undo_len(&self) -> usize {
- self.undo.len()
- }
-
- /// Another document: its nodes are not these.
- pub fn clear(&mut self) {
- self.undo.clear();
- self.redo.clear();
- self.group = None;
- }
-}
diff --git a/src/project.rs b/src/project.rs
index 546e84f..1767e55 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -439,6 +439,8 @@ impl State {
proj.migrate_param_refs();
crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
self.fs_root = proj.root;
+ // Another window's edits, or the bundled file: not this one's to undo.
+ self.structure_base = None;
self.migrate_meta_settings_node();
if !keep_own_view {
// Before the default view, whose camera-node rule has the last
@@ -519,7 +521,8 @@ 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.edit_history.clear();
+ self.structure_base = None;
self.migrate_meta_settings_node();
// As in the default-project branch.
if let Some(d) = &proj.view_state.display {
@@ -645,7 +648,8 @@ impl State {
inputs: 0,
outputs: 0,
};
- self.param_history.clear();
+ self.edit_history.clear();
+ self.structure_base = None;
self.migrate_meta_settings_node();
self.set_active_camera("Default Camera");
self.pan_x = 0.0;
diff --git a/src/window.rs b/src/window.rs
index 1807c3e..29ebab3 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -197,6 +197,8 @@ impl State {
}
result = true;
}
+ // Whatever the event did to the graph is a step of undo.
+ state.record_structure_changes();
}
result
}
@@ -694,6 +696,7 @@ impl State {
if needs_redraw {
*redraw = true;
}
+ state.record_structure_changes();
res
}
}