Class: Plushie::Undo

Inherits:
Object
  • Object
show all
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.

Examples:

u = Plushie::Undo.new(0)
cmd = { apply: ->(n) { n + 1 }, undo: ->(n) { n - 1 } }
u = Plushie::Undo.push(u, cmd)
Plushie::Undo.current(u)  #=> 1
u = Plushie::Undo.undo(u)
Plushie::Undo.current(u)  #=> 0

Defined Under Namespace

Classes: Entry, State

Constant Summary collapse

DEFAULT_MAX_SIZE =

Default maximum undo stack entries before oldest are dropped.

100

Class Method Summary collapse

Class Method Details

.can_redo?(u) ⇒ Boolean

Return true if there are entries on the redo stack.

Parameters:

Returns:

  • (Boolean)


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.

Parameters:

Returns:

  • (Boolean)


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.

Parameters:

Returns:

  • (Object)


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.

Parameters:

Returns:

  • (Array<String, nil>)


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.

Parameters:

  • model (Object)

    initial state

  • max_size (Integer) (defaults to: DEFAULT_MAX_SIZE)

    maximum undo entries (default 100). Oldest entries are dropped silently when exceeded.

Returns:



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+.

Parameters:

  • u (State)
  • command (Hash)

    must have :apply and :undo procs

Returns:

Raises:

  • (ArgumentError)


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 = timestamp
  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.

Parameters:

Returns:



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

.timestampObject

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.timestamp
  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.

Parameters:

Returns:



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