Undo and Redo

An armoury where every piece put on or taken off is a step. Undo, redo or jump to any step, from the list or with hotkeys. Each step holds the whole kit as a plain value on the server, so undo only moves back a step, and the page needs no JavaScript of its own.

The armoury

Helm

Bronze helm

Body

Nothing

Weapon

Bronze sword

Shield

Nothing
Attack
4
Defence
2
Weight
4 kg
Cost
100 gp

Ctrl or ⌘ + Z, with Shift to redo

Steps at 1 of 1

# frozen_string_literal: true

# The armoury. One state holds the history: every kit this tab has been
# through and which one it is at. An action replaces it with another
# history, and the kit on the page is derived from it. Undo has no list of
# things to put back.
class Recipes::Undo::UndoChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  History = Recipes::Undo::History
  Kit = Recipes::Undo::Kit
  Gear = Recipes::Undo::Gear
  VIEWS = "app/recipes/undo"

  def build_graph
    @history = Hibiki::State.new(History.start)
    kit = Hibiki::Derived.new { @history.value.kit }

    # The slots and the figures. Kits are compared by value, so a jump
    # between two steps that hold the same kit sends nothing here.
    Hibiki::Effect.new do
      broadcast_morph target: "undo_kit", partial: "#{VIEWS}/kit", locals: { kit: kit.value }
    end

    # Undo, Redo and the list of steps.
    Hibiki::Effect.new do
      broadcast_morph target: "undo_history", partial: "#{VIEWS}/history", locals: { history: @history.value }
    end
  end

  def wear(data)
    gear = Gear.find(data["key"].to_s)
    return unless gear

    aside = kit.displaced_by(gear)
    step kit.wear(gear), [ "#{gear.held? ? "Took up" : "Put on"} the #{gear}", ("#{aside} put away" if aside) ].compact.join(", ")
  end

  def remove(data)
    gear = (kit.piece(data["slot"].to_sym) if Kit::SLOTS.map(&:to_s).include?(data["slot"]))
    return unless gear

    step kit.remove(gear.slot), "#{gear.held? ? "Put down" : "Took off"} the #{gear}"
  end

  # Emptying the kit is a step like any other, so it asks nothing first.
  def strip = step(Kit.new, "Emptied the kit")

  def undo = @history.value = @history.value.undo

  def redo = @history.value = @history.value.redo

  def jump(data) = @history.value = @history.value.jump(Integer(data["at"], exception: false))

  private

  def kit = @history.value.kit

  def step(kit, label) = @history.value = @history.value.record(kit, label)
end

The panel above is an armoury. Press a piece to put it on, then press Undo, or Ctrl+Z (⌘+Z on a Mac), to take the step back. The figures under the slots follow, and so does the list of steps on the right. Take up the greatsword while holding a shield and undo that: the shield comes back too, though we didn't write any code to put it back. Undo is complicated in stock Rails, where the history has to be kept somewhere between two requests and every step has to know how to reverse itself. Here the history is one value on the server, and each step holds the entire kit.

How it fits together

An island is the part of the page Hibiki looks after, here the whole armoury. It belongs to a channel: the server side of an Action Cable connection, an object that stays alive while the page is open, one for each tab. The channel's public methods are its actions, the methods the page may call, and its states are values that remember who read them. Here the channel has one state, the history: every kit this tab has been through, and which one it is at.

A Kit is a plain Ruby value with four slots, each holding a piece or nothing. It is never changed in place: putting a piece on answers with a new kit. A History is a value too, a list of steps, each a kit and a line of text, and at, the step the tab is on.

A derived is a value computed from states and computed again when one of them changes. Here one derived reads the present kit out of the history. An effect is a block that runs again when a state or derived it read changes. One effect renders the slots and the figures from the kit, and another renders the Undo and Redo buttons and the list of steps from the history. This is what happens when you press a piece on a rack:

  1. The button calls wear with the piece's key.
  2. The action asks the present kit for a new kit with the piece on, and records it. Recording replaces the history state with a new history, one step longer.
  3. The derived kit is new, so the effect that read it renders the slots and the figures again. The history is new, so the effect that read it renders the list again: a line is added, and Undo turns on.

A step is the whole kit

The usual way to write undo is to remember what each action did and do the opposite. That gets hard as soon as one action changes two things. Taking up the greatsword means putting the shield away, so an undo written as the opposite of that action would have to remember that a shield was there and which shield it was. Here a step holds the whole kit as it was after the action. Undo doesn't have to reverse anything. It moves at back by one, and the page is rendered from the kit found at that step, shield and all.

# app/models/recipes/undo/history.rb
def record(kit, label)
  return self if kit == self.kit

  kept = [ *steps[..at], Step.new(kit:, label:) ].last(MAX)
  with(steps: kept, at: kept.size - 1)
end

def undo = jump(at - 1)

def redo = jump(at + 1)

def jump(index) = index.is_a?(Integer) && index.between?(0, steps.size - 1) ? with(at: index) : self

# app/channels/recipes/undo/undo_channel.rb
def undo = @history.value = @history.value.undo

def redo = @history.value = @history.value.redo

def jump(data) = @history.value = @history.value.jump(Integer(data["at"], exception: false))

record adds the new kit after the step the tab is on. If you undo a few steps and then put a piece on, the steps that were ahead are dropped, as in any editor. A kit equal to the present one is not recorded at all: press a piece you already wear, and record answers with the same history. The state sees an equal value, so no update is triggered.

The hotkeys

The Undo and Redo buttons also answer the keyboard. Press Ctrl+Z (⌘+Z on a Mac) to undo, and Ctrl+Shift+Z or Ctrl+Y to redo. The keys are written on the buttons, in the same on as the click:

<%# app/views/app/recipes/undo/_history.html.erb %>
<%= tag.button "Undo", type: "button", disabled: !history.undo?,
      **on(:undo, event: %w[click keydown.meta+z@window keydown.ctrl+z@window]) %>

<%= tag.button "Redo", type: "button", disabled: !history.redo?,
      **on(:redo, event: %w[click keydown.meta+shift+z@window keydown.ctrl+shift+z@window keydown.ctrl+y@window]) %>

Each entry in event: is something the button listens for. keydown.ctrl+z is a press of Z with Ctrl held down. A button normally hears a key only while it has focus, and @window lets it hear the key from anywhere on the page, so you don't have to click the button first.

When there is no step to go back to, the Undo button is disabled. The client doesn't call the action of a disabled button, whether it was clicked or its key was pressed, so Ctrl+Z sends nothing to the server.

The same feature in stock Rails

Kit, Gear and History would be the same classes in stock Rails. The difference is where the history is kept between two clicks. In Hibiki, the channel holds the history in memory for as long as the page is open, whereas in stock Rails a controller keeps no state between requests, so it reads the history from a table at the start of each one and writes it back at the end. Here is a sketch. It is not run here, and it leaves out the kit partial, whose buttons become forms in the same way as the ones shown.

# A sketch of the stock Rails version. It is not part of this app. Kit,
# Gear and History are the same classes in both versions.
class KitsController < ApplicationController
  before_action :set_history

  def show
  end

  def wear
    gear = Gear.find(params[:key]) or return head(:unprocessable_entity)
    aside = @history.kit.displaced_by(gear)
    step @history.kit.wear(gear), [ "Put on the #{gear}", ("#{aside} put away" if aside) ].compact.join(", ")
  end

  def remove
    gear = @history.kit.piece(params[:slot].presence_in(Kit::SLOTS.map(&:to_s))&.to_sym) or return head(:no_content)
    step @history.kit.remove(gear.slot), "Took off the #{gear}"
  end

  def strip = step(Kit.new, "Emptied the kit")

  def undo = move(@history.undo)

  def redo = move(@history.redo)

  def jump = move(@history.jump(params[:at].to_i))

  private

  # Nothing is remembered between requests, so the history is read from a
  # table at the start of each one and written back at the end. Thirty
  # steps do not fit in the 4 KB session cookie.
  def set_history
    @row = KitHistory.find_or_create_by!(session_id: session.id.to_s)
    @history = @row.to_history
  end

  def step(kit, label) = move(@history.record(kit, label))

  def move(history)
    @history = history
    @row.update!(history: history.as_json)

    respond_to do |format|
      format.turbo_stream { render :update }
      format.html { redirect_to kit_path }
    end
  end
end

The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
Where the history is kept A table row for each session, written as JSON and built back into values on every request, and a job to delete old rows One state on the server, holding the values
Undo, redo and jump Three routes, three actions and a form for each control Three one-line actions
The figures follow an undo A Turbo Stream line for each part of the page, in a template every action renders One derived and two effects, which no action names
A move that leaves the kit as it was The kit is sent again, unless the controller compares before and after Nothing is sent; the derived kit is equal
The keys Key filters in data-action, and a Stimulus controller that submits the right form Key events beside the click, in each button's on
A key whose button is disabled The controller checks disabled The client skips a disabled control
A new gesture that can be undone A route, an action and a form An action that calls record
The history after a reload Still there Gone, unless it is saved to a table as well

A controller keeps no state between two requests, so stock Rails has to put the history somewhere that lasts. One place is a table, as in the sketch above. Each click then reads a row, builds the kits back from JSON and writes the row again, and a job has to delete the rows that visitors leave behind.

The other place is the browser: keep the steps in a Stimulus controller. The prices and the rule for two-handed weapons are on the server, though. So either each undo posts a whole kit back to be priced, or those rules are written a second time in JavaScript.

Here the channel keeps the history in memory for as long as the tab is open, so the page needs no table. A reload starts the history over at "Walked into the armoury". If a page should keep its history after a reload, the channel can save the same value to a table as well, and read it back when the tab connects.