JS Commands

Some actions are better left to the browser. Here is an adventurer's backpack. Its tabs, item menus and examine windows open in the browser, with nothing sent to the server. Only actions that changes state, like dropping or wielding an item, tells the server.

Your backpack

Carrying 10.8 kg

10 of 28 spaces

  • Bronze pickaxe 2.3 kg
  • Tinderbox 0.1 kg
  • Logs 2.0 kg
  • Iron dagger 0.5 kg
  • Cabbage 0.5 kg
  • Rope 1.0 kg
  • Wooden shield 3.0 kg
  • Jug of ale 1.0 kg
  • Leather boots 0.4 kg
  • Gnome's map 0.0 kg

On the ground

Nothing. Drop something and it lands here.

Bronze pickaxe

Made for rocks. Used on most other things too.

Tinderbox

Flint and steel in a tin. Every campfire starts with one.

Logs

Dry enough to burn. The fire on the front page would take them.

Iron dagger

Short, sharp, and a little rusty at the hilt.

Cabbage

From the patch by the castle wall. Nobody saw you take it.

Rope

Thirty feet of it, give or take a knot.

Wooden shield

Planks and a strap. It will stop one arrow.

Jug of ale

Half full, which is better than half empty.

Leather boots

Broken in by somebody else, which is the best way.

Gnome's map

Drawn on the back of a mining permit. The X has been rubbed out.

# frozen_string_literal: true

# The backpack. The channel hears only the moves that change what is
# carried: wield or wear, remove, drop and pick up. The tabs, the menus, the
# marks and the examine windows are commands the browser runs by itself, so
# none of them is an action here.
class Recipes::Commands::BackpackChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Pack = Recipes::Commands::Pack
  VIEWS = "app/recipes/commands"

  def build_graph
    @pack = Hibiki::State.new(Pack.start)
    carried = Hibiki::Derived.new { @pack.value.carried }
    worn = Hibiki::Derived.new { @pack.value.worn }
    ground = Hibiki::Derived.new { @pack.value.ground }

    transmit_value(:backpack_count) { carried.value.size }
    transmit_value(:backpack_weight) { @pack.value.weight }

    # Each list renders only when it changes. A render puts the list back as
    # the server wrote it, which closes its menus and clears its marks.
    Hibiki::Effect.new do
      broadcast_morph target: "backpack_carried", partial: "#{VIEWS}/carried", locals: { items: carried.value }
    end
    Hibiki::Effect.new do
      broadcast_morph target: "backpack_worn", partial: "#{VIEWS}/worn", locals: { worn: worn.value }
    end
    Hibiki::Effect.new do
      broadcast_morph target: "backpack_ground", partial: "#{VIEWS}/ground", locals: { items: ground.value }
    end
  end

  def wear(data) = @pack.value = @pack.value.wear(data["key"].to_s)

  def remove(data) = @pack.value = @pack.value.remove(data["slot"].to_s)

  def drop(data) = @pack.value = @pack.value.drop(data["key"].to_s)

  def pick_up(data) = @pack.value = @pack.value.pick_up(data["key"].to_s)
end

The backpack above has two tabs and an Options button on every row. User interactions such as switching tabs, pressing Options, or picking Examine from the menu it opens all happen in the browser, and nothing is sent to the server. Wield, Drop and Remove do ask the server, since they change the pack. The clicks that stay in the browser are a short list of commands, written in the view beside the button.

A click that stays in the browser

On a Hibiki page a click usually goes to the server, and the server answers with HTML. That is right when the click changes something the server keeps, such as what is in the pack. Opening a menu changes nothing the server keeps. Sending it anyway costs a round trip, and a state in the channel for every menu on the page.

A command is a step the browser takes by itself when a control is used: show or hide an element, add or remove a class, set an attribute, move the cursor, or open a dialog. We write these with the js helper, and the on helper takes the list we wrote. Here are the Options and Drop buttons from one row:

<%# app/views/app/recipes/commands/_carried.html.erb %>
<%= tag.button "Options", type: "button", id: options,
      **on(js.hide(".backpack-menu:not(##{menu})").toggle("##{menu}").focus_first("##{menu}")) %>

<%= tag.button "Drop", type: "button",
      **on(js.hide({ closest: ".backpack-item" }).push(:drop, with: { key: item.key })) %>

Options hides every other menu, shows or hides its own, and puts the cursor in it. The browser runs the steps in order and does not wait between them.

A step has to know which element it acts on, and we tell it by giving it a target. Options names its targets with CSS selectors, which the browser looks up in the whole page: first every menu but this one, then this menu. Drop's { closest: ".backpack-item" } is the nearest ancestor of the button with that class, the row the button sits in. A step that names nothing acts on the control itself, except close_dialog, which closes the window the control sits in. That is how the Close button in an examine window works.

Both in one click

Drop needs the server as well, since the item has to leave the pack. push puts a channel action in the list. The pack lives in a channel, the server side of an Action Cable connection, which stays alive while the page is open. Its public methods, wear, drop and so on, are the actions the page may call. When Drop is clicked:

  1. The browser hides the row at once.
  2. drop reaches the channel, which moves the item from the pack to the ground.
  3. The channel renders the pack list and the ground again and sends both. The row is gone for good, the item appears on the ground, and the weight and the count of spaces follow.

Hiding the row at once takes for granted that the server will agree to the drop. The browser can't know that yet, since the server hasn't answered. In this pack the server always agrees, because any item can be dropped. If it could refuse, say for a cursed item that won't leave the pack, the row would vanish and then come back when the server rendered the list again. So hide a row early only when the action behind it cannot fail.

Why the marks disappear

Mark two items, then drop a third. The marks go. When the pack changes, the channel morphs the list, and a morph sets every element's attributes back to what the server wrote, classes and hidden included. An open menu in the list closes for the same reason.

So a command's change lasts until the server next renders that element. The tabs, the panels and the examine windows sit outside the three lists the channel renders, so the Worn tab stays open after Remove. A mark that had to last would be state on the server, rendered as part of the list.

A window the server never sees

Examine opens a <dialog> with show_modal, and its Close button runs close_dialog. The browser does the rest: it keeps the cursor inside the window, closes it on Escape, and gives the cursor back to Options. The server never learns the window was open.

JS Commands is a small Hibiki module

During install, bin/rails g hibiki:rails:install adds this line to your JS setup.

import "hibiki-rails/commands"

Using JS Commands is optional, and you can safely remove the import line if you do not wish to use it.

The backpack here asks the server only to move an item, and does the rest in the browser. A command acts at once; for an element that should slide or fade as it comes and goes, see Leave and Enter Transitions.