graphic design tool
git clone https://git.lucas.co/cce-designer.git
refactor: curve viewer state undo rides the toolkit's History and routing
The tool's hand-rolled stacks become a `cce_ui::history::History` of
point-list snapshots (a grab opens a gesture the first motion commits;
release cancels an uncommitted one), and the chords arrive through the
new `Application::undo`/`redo` hooks — the runner routes them after the
focused text box declines, so a param field being edited undoes its own
typing and the curve tool gets the chord otherwise. The app-local
Ctrl+Z dispatch and chord registrations go; `Action::Undo`/`Redo` stay
for the Edit menu rows.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SjC6ZXH9Z31WwLCLMjyBcd
CLAUDE.md | 11 +++++---
src/app.rs | 23 ++++------------
src/application.rs | 18 +++++++++++++
src/curve_tool.rs | 79 ++++++++++++++++++++----------------------------------
src/main.rs | 2 +-
5 files changed, 60 insertions(+), 73 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 7a4d38a..6af308b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -172,10 +172,13 @@ gone from cce-ui with the wgpu path).
`cursor_in_viewport() && !in_network_pane`, so the network plate keeps its
clicks where they overlap), motion at the top of CursorMoved, Escape ahead of
connection-cancel. The tool binds the node by ID and deactivates lazily when
- the id no longer resolves to a curve. Undo/redo (`Action::Undo`/`Redo`,
- `Ctrl+Z`/`Ctrl+Shift+Z` via input.kdl `undo`/`redo`, and Edit ▸ Undo/Redo)
- are point-list snapshots on the tool, one per gesture — a drag records on
- its first motion, so a no-move click leaves nothing. There is no
+ the id no longer resolves to a curve. Undo/redo is a
+ `cce_ui::history::History` of point-list snapshots on the tool, one per
+ gesture (a drag opens a gesture its first motion commits, so a no-move
+ click leaves nothing). The chords are the toolkit's (`undo`/`redo` in
+ input.kdl, routed by the runner to `Application::undo`/`redo` in
+ `application.rs` after the focused text box declines); Edit ▸ Undo/Redo
+ reach the same code through `Action::Undo`/`Redo`. There is no
project-wide history yet; `execute_action` is where one would be consulted
after the tool declines.
- `src/project.rs` — save/load. A project is a **directory containing `state.json`**
diff --git a/src/app.rs b/src/app.rs
index e92d4c9..4de3d82 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3616,8 +3616,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
register("play_pause_reverse", "Down", Action::PlayPauseReverse);
register("frame_next", "Right", Action::FrameNext);
register("frame_prev", "Left", Action::FramePrev);
- register("undo", "Ctrl+z", Action::Undo);
- register("redo", "Ctrl+Shift+z", Action::Redo);
}
let mut state = Self {
@@ -4643,9 +4641,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
let mut settings_changed = false;
match action {
// Undo/Redo reach whichever editing state owns a history. The
- // curve viewer state is the only one so far; when the app grows
- // a project-wide history this is where it would be consulted
- // after the tool declines.
+ // chords arrive through `Application::undo` / `redo` (the
+ // toolkit routes them, after the focused text box's turn); the
+ // Edit menu rows come here directly. The curve viewer state is
+ // the only history so far; a project-wide one would be consulted
+ // here after the tool declines.
Action::Undo => {
self.curve_tool_undo();
}
@@ -6157,19 +6157,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
{
return true;
}
- // Undo/Redo dispatch from any pane, like the transport above
- // and after the param pane's shot for the same reason. Today
- // the only history is the curve viewer state's; with no tool
- // active the chord is consumed and does nothing, rather than
- // falling through to become a stray "z" somewhere.
- if event.state == ElementState::Pressed {
- if let Some(action @ (Action::Undo | Action::Redo)) =
- self.shortcut_manager.match_action(&self.modifiers, &event.logical_key)
- {
- self.execute_action(action);
- return true;
- }
- }
let mut changed = false;
if event.state == ElementState::Pressed {
let is_plain_key = !self.modifiers.control_key() && !self.modifiers.alt_key() && !self.modifiers.super_key();
diff --git a/src/application.rs b/src/application.rs
index 128aa02..09f8f69 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -315,6 +315,24 @@ impl Application for State {
true
}
+ /// The toolkit's undo/redo routing lands here once no focused text box
+ /// wanted the chord. Only the curve viewer state has a history today.
+ fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
+ let taken = self.curve_tool_undo();
+ if taken {
+ *needs_rebuild = true;
+ }
+ taken
+ }
+
+ fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
+ let taken = self.curve_tool_redo();
+ if taken {
+ *needs_rebuild = true;
+ }
+ taken
+ }
+
fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<CustomEvent> {
self.sync_modifiers_from_ctx();
let ev = WindowEvent::KeyboardInput { event: event.clone() };
diff --git a/src/curve_tool.rs b/src/curve_tool.rs
index 66cde23..2d8cbfd 100644
--- a/src/curve_tool.rs
+++ b/src/curve_tool.rs
@@ -12,14 +12,16 @@
//! depth of the last control point, and immediately drags it;
//! - **right press on a handle** deletes that point; Delete/Backspace deletes
//! the selected (last-clicked) one;
-//! - **Ctrl+Z / Ctrl+Shift+Z** undo and redo, one gesture at a time: a whole
-//! drag is one step (recorded on the first motion after a grab, so a click
-//! that never moves records nothing), an add-and-drag is one step, a delete
-//! is one step. The history is snapshots of the point list, held on the tool
-//! itself — it lives exactly as long as the state does. Edits that arrive
-//! from outside the tool (the params pane, `curve_set_points` over MCP) are
-//! not recorded, but an undo still restores the list as it was before the
-//! last tool gesture, whatever happened since;
+//! - **undo / redo** (the toolkit chords, routed through `Application::undo`
+//! / `redo` once no focused text box wants them) step one gesture at a
+//! time: a whole drag is one step (recorded on the first motion after a
+//! grab, so a click that never moves records nothing), an add-and-drag is
+//! one step, a delete is one step. The history is a
+//! `cce_ui::history::History` of point-list snapshots on the tool itself —
+//! it lives exactly as long as the state does. Edits that arrive from
+//! outside the tool (the params pane, `curve_set_points` over MCP) are not
+//! recorded, but an undo still restores the list as it was before the last
+//! tool gesture, whatever happened since;
//! - **Escape** exits the state.
//!
//! Edits write the node's "Points" param through the same resync sequence as
@@ -30,14 +32,12 @@
use crate::app::{FsNode, State};
use crate::geometry::{format_curve_points, node_param_str, parse_curve_points};
+use cce_ui::history::History;
use glam::{Mat4, Vec3, Vec4};
/// How close (logical px) a press must land to a projected handle to grab it.
pub const HANDLE_HIT_RADIUS: f32 = 10.0;
-/// Undo depth kept per tool session; older snapshots fall off the front.
-pub const HISTORY_LIMIT: usize = 256;
-
pub struct CurveTool {
/// Id of the curve node being edited.
pub node_id: String,
@@ -45,38 +45,22 @@ pub struct CurveTool {
pub selected: Option<usize>,
/// An in-flight drag, if a press grabbed (or just added) a handle.
pub drag: Option<CurveDrag>,
- /// Point lists as they were before each recorded gesture, oldest first.
- pub undo: Vec<Vec<Vec3>>,
- /// Point lists undone and not yet redone, oldest first. Cleared by any
- /// new gesture.
- pub redo: Vec<Vec<Vec3>>,
+ /// Point-list snapshots, one per gesture. A grab opens a gesture that
+ /// the first motion commits, so a click that never moves records nothing.
+ pub history: History<Vec<Vec3>>,
}
impl CurveTool {
pub fn new(node_id: String) -> Self {
- CurveTool { node_id, selected: None, drag: None, undo: Vec::new(), redo: Vec::new() }
- }
-
- /// Record `before` as the state to return to on the next undo. Any new
- /// gesture forks the history, so the redo stack goes.
- fn record(&mut self, before: Vec<Vec3>) {
- self.undo.push(before);
- self.redo.clear();
- if self.undo.len() > HISTORY_LIMIT {
- let excess = self.undo.len() - HISTORY_LIMIT;
- self.undo.drain(..excess);
- }
+ CurveTool { node_id, selected: None, drag: None, history: History::new() }
}
}
-#[derive(Clone)]
+#[derive(Clone, Copy)]
pub struct CurveDrag {
pub point: usize,
/// NDC depth captured at grab time; motion unprojects onto this plane.
pub ndc_z: f32,
- /// The point list before the grab, until the first motion records it.
- /// A grab that releases without moving leaves no history entry.
- pub before: Option<Vec<Vec3>>,
}
/// World → (screen x, screen y, ndc z) through the cached scene mvp.
@@ -232,7 +216,8 @@ impl State {
if let Some((idx, ndc_z)) = self.curve_tool_handle_at_cursor() {
let tool = self.curve_tool.as_mut().expect("checked above");
tool.selected = Some(idx);
- tool.drag = Some(CurveDrag { point: idx, ndc_z, before: Some(pts) });
+ tool.history.begin_gesture(pts);
+ tool.drag = Some(CurveDrag { point: idx, ndc_z });
return true;
}
// Empty space: add a point. Depth comes from the last control point
@@ -259,10 +244,10 @@ impl State {
self.set_curve_points(&node_id, &pts);
if let Some(tool) = self.curve_tool.as_mut() {
// The add is the recorded step; the drag that follows it is
- // part of the same gesture, so `before` stays None.
- tool.record(before);
+ // part of the same gesture, so no gesture is opened for it.
+ tool.history.record(before);
tool.selected = Some(idx);
- tool.drag = Some(CurveDrag { point: idx, ndc_z, before: None });
+ tool.drag = Some(CurveDrag { point: idx, ndc_z });
}
true
}
@@ -270,7 +255,7 @@ impl State {
/// Pointer motion during a grab: the point tracks the cursor on the
/// camera-facing plane at its grab depth.
pub(crate) fn curve_tool_drag_motion(&mut self) -> bool {
- let Some(drag) = self.curve_tool.as_ref().and_then(|t| t.drag.clone()) else {
+ let Some(drag) = self.curve_tool.as_ref().and_then(|t| t.drag) else {
return false;
};
let Some(mvp) = self.last_scene_mvp else {
@@ -291,9 +276,7 @@ impl State {
};
pts[drag.point] = world;
if let Some(tool) = self.curve_tool.as_mut() {
- if let Some(before) = tool.drag.as_mut().and_then(|d| d.before.take()) {
- tool.record(before);
- }
+ tool.history.commit_gesture();
}
self.set_curve_points(&node_id, &pts);
true
@@ -304,6 +287,7 @@ impl State {
match self.curve_tool.as_mut() {
Some(tool) if tool.drag.is_some() => {
tool.drag = None;
+ tool.history.cancel_gesture();
true
}
_ => false,
@@ -344,7 +328,7 @@ impl State {
pts.remove(idx);
self.set_curve_points(&node_id, &pts);
if let Some(tool) = self.curve_tool.as_mut() {
- tool.record(before);
+ tool.history.record(before);
tool.drag = None;
// Keep a neighbor selected so repeated Delete walks the curve.
tool.selected = if pts.is_empty() {
@@ -356,13 +340,13 @@ impl State {
true
}
- /// Ctrl+Z: return the points to how they were before the last recorded
+ /// Undo: return the points to how they were before the last recorded
/// gesture. Consumes only when the tool is active and has history.
pub(crate) fn curve_tool_undo(&mut self) -> bool {
self.curve_tool_step(true)
}
- /// Ctrl+Shift+Z: reapply the last undone gesture.
+ /// Redo: reapply the last undone gesture.
pub(crate) fn curve_tool_redo(&mut self) -> bool {
self.curve_tool_step(false)
}
@@ -377,15 +361,10 @@ impl State {
return false;
};
let tool = self.curve_tool.as_mut().expect("checked above");
- let (from, to) = if undo {
- (&mut tool.undo, &mut tool.redo)
- } else {
- (&mut tool.redo, &mut tool.undo)
- };
- let Some(target) = from.pop() else {
+ let stepped = if undo { tool.history.undo(current) } else { tool.history.redo(current) };
+ let Some(target) = stepped else {
return false;
};
- to.push(current);
// A step mid-drag abandons the drag: the grabbed index may not
// exist in the restored list, and the pointer no longer means
// anything to it.
diff --git a/src/main.rs b/src/main.rs
index 95f90c3..264e634 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1588,7 +1588,7 @@ mod tests {
};
let history = |state: &State| {
let t = state.curve_tool.as_ref().expect("tool active");
- (t.undo.len(), t.redo.len())
+ (t.history.undo_len(), t.history.redo_len())
};
let initial = points_of(&state);