Harlock.Cmd (harlock v0.8.0)

Copy Markdown View Source

Side-effect descriptors returned from init/1 and update/2.

An app's update/2 can return {new_model, cmd} to request side-effects. The runtime dispatches the cmd to a Task.Supervisor and re-enters the TEA loop; results arrive later as ordinary {:harlock_event, result} messages that update/2 handles like any other event.

def update(:fetch, model) do
  cmd = Cmd.from(fn -> HTTPoison.get!("https://example.com") end)
  {model, cmd}
end

def update({:ok, %{body: body}}, model), do: {%{model | body: body}, Cmd.none()}

Constructors:

  • none/0 — no side-effect. Equivalent to returning just the model.
  • from/1 — run a 0-arity function in a task; deliver its return value.
  • exec/3 — hand the terminal to another program until it exits.
  • suspend/0 — stop the app for the shell's job control, as Ctrl-Z does.
  • batch/1 — dispatch a list of cmds concurrently; no ordering guarantee.
  • map/2 — tag/transform the result of an inner cmd before delivery.

Running another program

exec/3 suspends the app's use of the terminal, runs a program with it, and resumes when the program exits — how an editor, pager, or git commit is run from a terminal UI:

def update({:harlock_submit, :edit}, m) do
  {m, Cmd.exec("vim", [m.path]) |> Cmd.map(&{:edited, &1})}
end

def update({:edited, {:ok, 0}}, m), do: reload(m)
def update({:edited, _failed}, m), do: %{m | status: "editor failed"}

While the program runs it owns the terminal completely: the app's alternate screen is left, the terminal settings the shell had before the app started are restored, and keystrokes, Ctrl-C and window resizes go to the program. The app keeps running — subscriptions and other cmd results still reach update/2 — but nothing is drawn. On exit the app redraws at the current size and receives the result:

  • {:ok, exit_status} — the program exited, with any status.
  • {:error, {:signal, n}} — it was killed by signal n (2 for Ctrl-C).
  • {:error, {:exec, reason}} — it could not be started, e.g. :enoent when program is not on PATH.
  • {:error, {:chdir, reason}} — the :cd directory could not be entered.
  • {:error, :killed} — the program was killed with the app, which ended while it ran.
  • {:error, :busy} — another program is already running.
  • {:error, :not_foreground} — the app does not hold the terminal's foreground, so it cannot hand it over.
  • other {:error, {stage, reason}} pairs for failures setting the program up (opening the terminal, a pipe, starting it), which are rare.
  • {:error, :no_terminal} — the app is not attached to a terminal, as under the test backend without an :exec stub (see Harlock.Test.start_app/3).

exec/3 needs the app to own its terminal: run it with mix run, not from an IEx prompt, whose own terminal driver competes for input.

Suspending

In raw mode Ctrl-Z is an ordinary key, {:key, {:char, ?z}, [:ctrl]}, not a suspend: the terminal driver no longer turns it into a signal. An app that wants the usual job-control behaviour — Ctrl-Z to the shell, fg to come back — binds it to suspend/0:

def update({:key, {:char, ?z}, [:ctrl]}, m), do: {m, Cmd.suspend()}

The terminal is handed back to the shell as for exec/3, the app stops, and after fg it redraws at the current size and receives {:ok, :resumed}. It is not bound by default because apps use Ctrl-Z for other things, undo among them.

Suspending needs a shell with job control to resume the app. Without one — the app started by a script, by exec, or under script(1) — it returns {:error, :no_job_control} and leaves the terminal as it was, rather than stopping with nothing to resume it. Other results: {:error, :busy} while an exec/3 program runs, {:error, :no_terminal} under the test backend without a :suspend stub, and {:error, :not_stopped} if the stop was requested but did not happen, in which case the app simply carries on.

Task lifecycle: from/1 tasks run under a task supervisor in the app's supervision tree, started before the runtime. They end with the app: quitting stops the tree, and so does a crash. A crash inside a task is caught at the task body and delivered as {:cmd_error, reason}; it never propagates to the runtime. exec/3 and suspend/0 are not tasks — the runtime carries them out itself.

Summary

Types

t()

An opaque cmd descriptor. Build one via the constructors.

Functions

Run program with the terminal until it exits. See "Running another program" above.

Stop the app for the shell's job control, as Ctrl-Z would. See "Suspending" above.

Types

t()

@opaque t()

An opaque cmd descriptor. Build one via the constructors.

Functions

batch(cmds)

@spec batch([t()]) :: t()

exec(program, args \\ [], opts \\ [])

@spec exec(String.t(), [String.t()], keyword()) :: t()

Run program with the terminal until it exits. See "Running another program" above.

program is looked up on PATH unless it contains a /. args are passed as-is, with no shell in between — use exec("sh", ["-c", script]) when a shell is wanted.

Options:

  • :cd — directory to run the program in.
  • :env — environment variables to set for the program, as {name, value} pairs or a map, on top of the current environment. A nil value unsets the variable.

from(fun)

@spec from((-> any())) :: t()

map(cmd, fun)

@spec map(t(), (any() -> any())) :: t()

none()

@spec none() :: t()

suspend()

@spec suspend() :: t()

Stop the app for the shell's job control, as Ctrl-Z would. See "Suspending" above.