Class: Plushie::Undo
- Inherits:
-
Object
- Object
- Plushie::Undo
- Defined in:
- lib/plushie/undo.rb
Overview
Undo/redo stack for reversible commands. Pure data structure, no threads.
Each command provides an +apply+ proc and an +undo+ proc. The stack tracks entries so that undo moves an entry to the redo stack (calling the undo proc) and redo moves it back (calling the apply proc).
== Max size
The undo stack is bounded by +:max_size+ (default 100). When a push exceeds the limit, the oldest entries are dropped. The redo stack is unbounded (it can only shrink or be cleared, never grow past the undo stack size).
== Coalescing
Commands with the same +:coalesce+ key that arrive within +:coalesce_window_ms+ of each other are merged into a single undo entry. The merged entry keeps the original undo proc (so one undo reverses all coalesced changes) and composes the apply procs.
Defined Under Namespace
Constant Summary collapse
- DEFAULT_MAX_SIZE =
Default maximum undo stack entries before oldest are dropped.
100
Class Method Summary collapse
-
.can_redo?(u) ⇒ Boolean
Return true if there are entries on the redo stack.
-
.can_undo?(u) ⇒ Boolean
Return true if there are entries on the undo stack.
-
.current(u) ⇒ Object
Return the current model.
-
.history(u) ⇒ Array<String, nil>
Return the labels from the undo stack, most recent first.
-
.new(model, max_size: DEFAULT_MAX_SIZE) ⇒ State
Create a new undo stack with +model+ as the initial state.
-
.push(u, command) ⇒ State
Push a command onto the undo stack, updating the current model.
-
.redo(u) ⇒ State
Redo the last undone command.
-
.timestamp ⇒ Object
private
Timestamp source.
-
.undo(u) ⇒ State
Undo the last command.
Class Method Details
.can_redo?(u) ⇒ Boolean
Return true if there are entries on the redo stack.
175 176 177 |
# File 'lib/plushie/undo.rb', line 175 def self.can_redo?(u) !u.redo_stack.empty? end |
.can_undo?(u) ⇒ Boolean
Return true if there are entries on the undo stack.
167 168 169 |
# File 'lib/plushie/undo.rb', line 167 def self.can_undo?(u) !u.undo_stack.empty? end |
.current(u) ⇒ Object
Return the current model.
161 |
# File 'lib/plushie/undo.rb', line 161 def self.current(u) = u.current |
.history(u) ⇒ Array<String, nil>
Return the labels from the undo stack, most recent first.
183 184 185 |
# File 'lib/plushie/undo.rb', line 183 def self.history(u) u.undo_stack.map(&:label) end |
.new(model, max_size: DEFAULT_MAX_SIZE) ⇒ State
Create a new undo stack with +model+ as the initial state.
58 59 60 61 62 63 64 |
# File 'lib/plushie/undo.rb', line 58 def self.new(model, max_size: DEFAULT_MAX_SIZE) unless max_size.is_a?(Integer) && max_size > 0 raise ArgumentError, "expected max_size to be a positive integer, got: #{max_size.inspect}" end State.new(current: model, max_size: max_size, undo_size: 0, undo_stack: [], redo_stack: []) end |
.push(u, command) ⇒ State
Push a command onto the undo stack, updating the current model. Clears the redo stack.
If the command carries a +:coalesce+ key that matches the top of the undo stack and the time delta is within +:coalesce_window_ms+, the entry is merged rather than pushed.
The command must be a Hash with +:apply+ and +:undo+ keys (both callable). Optional keys: +:label+, +:coalesce+, +:coalesce_window_ms+.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/plushie/undo.rb', line 79 def self.push(u, command) callable = command[:apply] raise ArgumentError, "command :apply must be callable" unless callable.respond_to?(:call) raise ArgumentError, "command :undo must be callable" unless command[:undo].respond_to?(:call) now = new_model = callable.call(u.current) coalesced = maybe_coalesce(u, command, now) if coalesced u.with( current: new_model, undo_stack: [coalesced, *u.undo_stack[1..]], redo_stack: [] ) else entry = Entry.new( apply_fn: command[:apply], undo_fn: command[:undo], label: command[:label], coalesce: command[:coalesce], timestamp: now ) new_stack = [entry, *u.undo_stack] new_size = u.undo_size + 1 # Trim oldest entries if over max_size if new_size > u.max_size new_stack = new_stack[0, u.max_size] new_size = u.max_size end u.with( current: new_model, undo_stack: new_stack, undo_size: new_size, redo_stack: [] ) end end |
.redo(u) ⇒ State
Redo the last undone command. Returns unchanged if the redo stack is empty.
143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/plushie/undo.rb', line 143 def self.redo(u) return u if u.redo_stack.empty? entry = u.redo_stack.first new_model = entry.apply_fn.call(u.current) u.with( current: new_model, redo_stack: u.redo_stack[1..], undo_stack: [entry, *u.undo_stack], undo_size: u.undo_size + 1 ) end |
.timestamp ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Timestamp source. Override via thread-local for deterministic tests.
48 49 50 |
# File 'lib/plushie/undo.rb', line 48 def self. Thread.current[:plushie_undo_timestamp] || Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) end |
.undo(u) ⇒ State
Undo the last command. Returns unchanged if the undo stack is empty.
125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/plushie/undo.rb', line 125 def self.undo(u) return u if u.undo_stack.empty? entry = u.undo_stack.first old_model = entry.undo_fn.call(u.current) u.with( current: old_model, undo_stack: u.undo_stack[1..], undo_size: u.undo_size - 1, redo_stack: [entry, *u.redo_stack] ) end |