defmodule Harlock do
@moduledoc """
Harlock — a pure-Elixir TUI framework for Unix terminals.
The two entry points:
* `Harlock.run/2` — blocking. Starts the app, returns when it exits.
* `Harlock.start_link/2` — non-blocking. Returns a supervisor pid for
embedding inside an existing OTP application.
v0.1 is under construction; the smoke functions are scaffold tooling.
"""
alias Harlock.Terminal.{Ansi, Caps, Tty}
_ = Caps
@type app :: module()
@type init_arg :: any()
@doc """
Start an app and block until it exits. Returns `{:ok, reason}` on normal
exit, `{:error, reason}` if the supervisor failed to start.
The caller's process is linked to the app supervisor, so killing the caller
tears down the supervisor cleanly — which in turn restores the terminal.
Options:
* `:theme` — a `Harlock.Theme.t()` (or keyword list / map convertible
via `Harlock.Theme.build/1`). Defaults to `Harlock.Theme.default/0`.
"""
@spec run(app(), init_arg(), keyword()) :: {:ok, term()} | {:error, term()}
def run(app, init_arg \\ nil, opts \\ []) do
caller = self()
sup_opts =
[app: app, init_arg: init_arg, caller: caller]
|> maybe_put_theme(opts)
case Harlock.App.Supervisor.start_link(sup_opts) do
{:ok, sup} ->
ref = Process.monitor(sup)
receive do
{:harlock_done, reason} ->
Supervisor.stop(sup, :normal)
{:ok, reason}
{:DOWN, ^ref, :process, ^sup, reason} ->
{:error, reason}
end
{:error, _} = err ->
err
end
end
@doc """
Start an app under the caller's supervision tree without blocking.
Returns `{:ok, sup_pid}`. The caller is responsible for handling the
supervisor's lifecycle (linking, monitoring, stopping). Useful when
embedding Harlock inside a larger OTP app — e.g. a Phoenix project with a
dev-mode TUI dashboard.
Accepts the same options as `run/3`.
"""
@spec start_link(app(), init_arg(), keyword()) :: Supervisor.on_start()
def start_link(app, init_arg \\ nil, opts \\ []) do
sup_opts =
[app: app, init_arg: init_arg, caller: self()]
|> maybe_put_theme(opts)
Harlock.App.Supervisor.start_link(sup_opts)
end
defp maybe_put_theme(sup_opts, opts) do
case Keyword.fetch(opts, :theme) do
{:ok, theme} -> Keyword.put(sup_opts, :theme, Harlock.Theme.build(theme))
:error -> sup_opts
end
end
@doc """
Round-trip smoke test. Opens /dev/tty, writes a greeting in the alt-screen,
reads up to 5 bytes from the user, echoes what was typed, and restores the
terminal. Should leave the terminal in a usable state after returning.
"""
@spec smoke() :: binary() | {:error, term()}
def smoke do
Tty.with_raw(fn rfd, wfd ->
:ok = Tty.write(wfd, [Ansi.move(1, 2), "Harlock smoke test."])
:ok = Tty.write(wfd, [Ansi.move(3, 2), "Press 5 keys..."])
bytes = read_n(rfd, 5, [])
:ok = Tty.write(wfd, [Ansi.move(5, 2), "You typed: ", inspect(bytes)])
Process.sleep(1200)
bytes
end)
end
@doc """
Crash-path smoke test. Spawns a linked child that opens /dev/tty and then
raises. `with_raw`'s nested `try/after` blocks restore the terminal before
the process dies. Returns `{cleanup, reason}` where cleanup is `:ok` if
the restore ran.
"""
@spec smoke_crash() :: {:ok | :no_cleanup | term(), term()}
def smoke_crash do
parent = self()
{pid, ref} =
spawn_monitor(fn ->
try do
Tty.with_raw(fn _rfd, wfd ->
:ok = Tty.write(wfd, [Ansi.move(1, 2), "About to crash..."])
Process.sleep(400)
raise "intentional crash to verify terminal restoration"
end)
after
send(parent, :cleanup_ran)
end
end)
cleanup =
receive do
:cleanup_ran -> :ok
after
5_000 -> :no_cleanup
end
reason =
receive do
{:DOWN, ^ref, :process, ^pid, r} -> r
after
2_000 -> :no_exit
end
{cleanup, reason}
end
defp read_n(_fd, remaining, acc) when remaining <= 0 do
acc |> Enum.reverse() |> IO.iodata_to_binary()
end
defp read_n(fd, remaining, acc) do
case Tty.read(fd, max(remaining, 16)) do
{:ok, bytes} ->
read_n(fd, remaining - byte_size(bytes), [bytes | acc])
{:error, :eintr} ->
read_n(fd, remaining, acc)
:eof ->
acc |> Enum.reverse() |> IO.iodata_to_binary()
{:error, _reason} ->
acc |> Enum.reverse() |> IO.iodata_to_binary()
end
end
end