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 helmBody
NothingWeapon
Bronze swordShield
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
# frozen_string_literal: true
# Every kit this tab has been through, and which one it is at. Undo, redo
# and a jump all move `at`. A new step is added after `at`, and the steps
# that were ahead of it are dropped.
class Recipes::Undo::History < Data.define(:steps, :at)
Kit = Recipes::Undo::Kit
Step = Data.define(:kit, :label)
# The oldest steps go once there are more than this.
MAX = 30
def self.start = new(steps: [ Step.new(kit: Kit.start, label: "Walked into the armoury") ], at: 0)
def present = steps[at]
def kit = present.kit
# A kit equal to the present one is not a step.
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
def undo? = at.positive?
def redo? = ahead.positive?
# How many steps a redo can still reach.
def ahead = steps.size - 1 - at
end
# frozen_string_literal: true
# What an adventurer is wearing: one piece in each slot, or none. A kit is
# never changed. Putting a piece on answers with another kit, which is what
# lets the history keep every one of them.
class Recipes::Undo::Kit < Data.define(:helm, :body, :weapon, :shield)
Gear = Recipes::Undo::Gear
SLOTS = members.freeze
# More than this and there is no running.
MAX_WEIGHT = 24
# What a visitor walks in wearing.
def self.start = new(helm: "bronze_helm", weapon: "bronze_sword")
def initialize(helm: nil, body: nil, weapon: nil, shield: nil) = super
# A two-handed weapon leaves no hand for a shield, and a shield none for
# a two-handed weapon. So one piece put on can take another off.
def wear(gear)
aside = displaced_by(gear)
kit = with(gear.slot => gear.key)
aside ? kit.with(aside.slot => nil) : kit
end
# The piece in another slot that has to go when `gear` comes.
def displaced_by(gear)
if gear.two_handed
piece(:shield)
elsif gear.slot == :shield && two_handed?
piece(:weapon)
end
end
def remove(slot) = with(slot => nil)
def piece(slot) = Gear.find(public_send(slot))
def pieces = SLOTS.filter_map { piece(it) }
def empty? = pieces.empty?
def two_handed? = piece(:weapon)&.two_handed || false
def attack = pieces.sum(&:attack)
def defence = pieces.sum(&:defence)
def weight = pieces.sum(&:weight)
def cost = pieces.sum(&:price)
def encumbered? = weight > MAX_WEIGHT
# The metal, when all four slots hold the same one.
def full_set
metals = pieces.map(&:metal).uniq
metals.first if pieces.size == SLOTS.size && metals.size == 1
end
end
# frozen_string_literal: true
# One piece on the armoury's racks. The racks are a constant: nothing here
# is bought or sold, only tried on.
class Recipes::Undo::Gear < Data.define(:key, :name, :slot, :metal, :attack, :defence, :weight, :price, :two_handed)
ALL = [
[ "bronze_helm", "Bronze helm", :helm, "bronze", 0, 2, 2, 40 ],
[ "iron_helm", "Iron helm", :helm, "iron", 0, 4, 3, 110 ],
[ "steel_helm", "Steel helm", :helm, "steel", 0, 7, 4, 280 ],
[ "bronze_plate", "Bronze breastplate", :body, "bronze", 0, 6, 8, 120 ],
[ "iron_plate", "Iron breastplate", :body, "iron", 0, 11, 10, 340 ],
[ "steel_plate", "Steel breastplate", :body, "steel", 0, 18, 12, 900 ],
[ "bronze_sword", "Bronze sword", :weapon, "bronze", 4, 0, 2, 60 ],
[ "iron_sword", "Iron sword", :weapon, "iron", 8, 0, 3, 170 ],
[ "steel_sword", "Steel sword", :weapon, "steel", 13, 0, 3, 420 ],
[ "greatsword", "Greatsword", :weapon, "steel", 22, 0, 9, 760, true ],
[ "longbow", "Longbow", :weapon, nil, 16, 0, 2, 300, true ],
[ "bronze_shield", "Bronze shield", :shield, "bronze", 0, 4, 5, 70 ],
[ "iron_shield", "Iron shield", :shield, "iron", 0, 8, 6, 200 ],
[ "steel_shield", "Steel shield", :shield, "steel", 0, 13, 7, 520 ]
].to_h do |key, name, slot, metal, attack, defence, weight, price, two_handed = false|
[ key, new(key:, name:, slot:, metal:, attack:, defence:, weight:, price:, two_handed:) ]
end.freeze
def self.find(key) = ALL[key]
def self.on(slot) = ALL.values.select { it.slot == slot }
# A weapon or a shield is taken up and put down. Armour is put on and
# taken off.
def held? = slot.in?(%i[weapon shield])
def to_s = name.downcase
# What its button says, under the heading of its slot.
def short = name.split.first
end
<%# The page renders the first history with the same History the channel
starts from. Everything inside the island is morphed by the channel. %>
<% history = Recipes::Undo::History.start %>
<%= island Recipes::Undo::UndoChannel, class: "panel mx-auto w-full max-w-3xl p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">The armoury</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<div class="mt-3 grid gap-5 md:grid-cols-[minmax(0,1fr)_17rem]">
<%= render "app/recipes/undo/kit", kit: history.kit %>
<%= render "app/recipes/undo/history", history: %>
</div>
<% end %>
<%# locals: (kit:) -%>
<%# The slots and what the kit comes to. It only asks the kit; nothing is
added up here. %>
<div id="undo_kit" class="min-w-0">
<% Recipes::Undo::Kit::SLOTS.each do |slot| %>
<% worn = kit.piece(slot) %>
<section id="undo_slot_<%= slot %>" class="mb-3">
<div class="flex items-baseline justify-between gap-2">
<h3 class="text-sm font-semibold"><%= slot.to_s.humanize %></h3>
<span class="text-sm opacity-70">
<%= worn&.name || (slot == :shield && kit.two_handed? ? "Both hands are on the #{kit.piece(:weapon)}" : "Nothing") %>
</span>
</div>
<div class="mt-1 flex flex-wrap gap-1">
<% Recipes::Undo::Gear.on(slot).each do |gear| %>
<%= tag.button gear.short, type: "button", "aria-pressed": gear == worn,
class: [ "btn btn-sm", ("btn-primary" if gear == worn) ], **on(:wear, with: { key: gear.key }) %>
<% end %>
<%= tag.button "None", type: "button", class: "btn btn-ghost btn-sm", disabled: worn.nil?,
**on(:remove, with: { slot: }) %>
</div>
</section>
<% end %>
<dl class="mt-4 grid grid-cols-4 gap-2 border-t-2 border-base-300 pt-3 text-center tabular-nums">
<div><dt class="text-xs opacity-70">Attack</dt><dd id="undo_attack" class="text-lg font-bold"><%= kit.attack %></dd></div>
<div><dt class="text-xs opacity-70">Defence</dt><dd id="undo_defence" class="text-lg font-bold"><%= kit.defence %></dd></div>
<div>
<dt class="text-xs opacity-70">Weight</dt>
<dd id="undo_weight" class="text-lg font-bold <%= "text-error" if kit.encumbered? %>"><%= kit.weight %> kg</dd>
</div>
<div><dt class="text-xs opacity-70">Cost</dt><dd id="undo_cost" class="text-lg font-bold"><%= kit.cost.to_fs(:delimited) %> gp</dd></div>
</dl>
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<%= tag.button "Empty the kit", type: "button", class: "btn btn-sm", disabled: kit.empty?, **on(:strip) %>
<p class="text-sm">
<% if kit.full_set %>
<%= rainbow_text "A full set of #{kit.full_set}!", class: "font-bold" %>
<% end %>
<% if kit.encumbered? %>
<span id="undo_heavy">Over <%= Recipes::Undo::Kit::MAX_WEIGHT %> kg: too heavy to run in.</span>
<% end %>
</p>
</div>
</div>
<%# locals: (history:) -%>
<%# Undo, Redo and every step, newest first. The keys are declared on the
two buttons, so a disabled button's key does nothing either. %>
<div id="undo_history" class="min-w-0">
<div class="flex items-center gap-2">
<%= tag.button "Undo", id: "undo_back", type: "button", class: "btn btn-primary btn-sm flex-1", disabled: !history.undo?,
**on(:undo, event: %w[click keydown.meta+z@window keydown.ctrl+z@window]) %>
<%= tag.button "Redo", id: "undo_forward", type: "button", class: "btn btn-primary btn-sm flex-1", disabled: !history.redo?,
**on(:redo, event: %w[click keydown.meta+shift+z@window keydown.ctrl+shift+z@window keydown.ctrl+y@window]) %>
</div>
<p class="mt-2 text-xs opacity-70">
<kbd class="kbd kbd-xs">Ctrl</kbd> or <kbd class="kbd kbd-xs">⌘</kbd> + <kbd class="kbd kbd-xs">Z</kbd>,
with <kbd class="kbd kbd-xs">Shift</kbd> to redo
</p>
<h3 class="mt-3 flex items-baseline justify-between text-sm font-semibold">
Steps
<span id="undo_place" class="font-normal tabular-nums opacity-70">at <%= history.at + 1 %> of <%= history.steps.size %></span>
</h3>
<ol id="undo_steps" class="mt-1 max-h-64 overflow-y-auto md:h-64 rounded-box border-2 border-base-300 bg-base-100 p-1">
<% history.steps.each_with_index.reverse_each do |step, index| %>
<li id="undo_step_<%= index %>">
<%= tag.button type: "button", "aria-current": ("step" if index == history.at),
class: [ "flex w-full gap-2 rounded px-2 py-1 text-left text-sm hover:bg-base-200",
("bg-base-300 font-semibold" if index == history.at), ("opacity-50" if index > history.at) ],
**on(:jump, with: { at: index }) do %>
<span class="w-5 shrink-0 text-right tabular-nums opacity-60"><%= index + 1 %></span>
<span class="min-w-0"><%= step.label %></span>
<% end %>
</li>
<% end %>
</ol>
</div>
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:
-
The button calls
wearwith the piece's key. - 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.
- 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
# A sketch of the stock Rails version. It is not part of this app.
#
# create_table :kit_histories do |t|
# t.string :session_id, null: false, index: { unique: true }
# t.json :history, null: false, default: {}
# t.timestamps
# end
class KitHistory < ApplicationRecord
# The history goes to JSON and comes back as hashes, so the values are
# built again on every request.
def to_history
return History.start if history.blank?
steps = history["steps"].map { History::Step.new(kit: Kit.new(**it["kit"].symbolize_keys), label: it["label"]) }
History.new(steps:, at: history["at"])
end
# A row is left behind by every visitor who never comes back. A
# recurring job calls this.
def self.sweep = where(updated_at: ...1.day.ago).delete_all
end
<%# One template for all six actions. It names each part of the page that
an action can change, and sends both whether or not they did. %>
<%= turbo_stream.replace "kit", partial: "kits/kit", locals: { kit: @history.kit } %>
<%= turbo_stream.replace "history", partial: "kits/history", locals: { history: @history } %>
<%# Each control is a form of its own, posting to a route of its own. %>
<div id="history" data-controller="hotkeys"
data-action="keydown.meta+z@window->hotkeys#undo keydown.ctrl+z@window->hotkeys#undo
keydown.meta+shift+z@window->hotkeys#redo keydown.ctrl+shift+z@window->hotkeys#redo
keydown.ctrl+y@window->hotkeys#redo">
<%= button_to "Undo", undo_kit_path, disabled: !history.undo?, data: { hotkeys_target: "undo" } %>
<%= button_to "Redo", redo_kit_path, disabled: !history.redo?, data: { hotkeys_target: "redo" } %>
<ol>
<% history.steps.each_with_index.reverse_each do |step, index| %>
<li>
<%= button_to step.label, jump_kit_path, params: { at: index },
aria: { current: ("step" if index == history.at) } %>
</li>
<% end %>
</ol>
</div>
// A sketch of the stock Rails version. It is not part of this app.
import { Controller } from "@hotwired/stimulus"
// Stimulus hears the keys. Turning one into a request is this controller.
export default class extends Controller {
static targets = ["undo", "redo"]
undo(event) {
this.press(this.undoTarget, event)
}
redo(event) {
this.press(this.redoTarget, event)
}
press(button, event) {
if (event.repeat || button.disabled) return
event.preventDefault()
button.form.requestSubmit(button)
}
}
# A sketch of the stock Rails version. It is not part of this app.
resource :kit, only: :show do
post :wear # a piece put on
post :remove # a slot emptied
post :strip # the kit emptied
post :undo
post :redo
post :jump
end
The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| 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.