lib/ex_term.ex

defmodule ExTerm do
  @moduledoc """
  ## Description

  ExTerm is a terminal `Phoenix.LiveView`.  By default, ExTerm provides an IEx
  terminal usable as a web interface, however, this is customizable and you may
  use ExTerm for other CLIs.

  Under the hood, ExTerm is a generic interface for converting erlang IO protocol
  messages into web output and translating web input into responses in the IO
  protocol.

  ## Installation

  Add ExTerm to your mix.exs:

  ```elixir

  def deps do
  [
    # ...
    {:ex_term, "~> 0.2"}
    # ...
  ]
  end
  ```

  ### How to create a live terminal in your Phoenix router

  ExTerm provides the convenience helper `ExTerm.Router.live_term/3` that you
  can use to create a live view route.

  You must supply a `Phoenix.PubSub` server that is the communication channel
  to send important updates to the liveview.  It's recommended to use the
  PubSub server associated with your web server.

  The default backend is `ExTerm.TerminalBackend` and the default terminal is
  `IEx.Server`.  Both of these are customizable.

  - with the default backend and default terminal

    ```elixir
    import ExTerm.Router

    scope "/live_term" do
      pipe_through :browser

      live_term "/", pubsub_server: MyAppWeb.PubSub
    end
    ```

  - with the default backend and a custom interaction layer

    ```elixir
    import ExTerm.Router

    scope "/live_term" do
      pipe_through :browser

      live_term "/", pubsub_server: MyAppWeb.PubSub, terminal: {__MODULE__, :function, []}
    end
    ```

  - with a custom backend


    ```elixir
    import ExTerm.Router

    scope "/live_term" do
      pipe_through :browser

      live_term "/", MyBackend, pubsub_server: MyAppWeb.PubSub
    end
    ```

  ### Customizing layout (CSS)

  You can customize the css for the layout, by providing either a builtin
  layout option or providing your own.  To use a builtin layout, pass the
  layout name `:default` or `:bw` (for black and white console text) as
  the css option, as follows:

  ```elixir
    live_term "/", pubsub_server: MyAppWeb.PubSub, css: :bw
  ```

  To use a custom layout, put the layout file in the `priv` directory of your
  applicatyon and pass the relative path as follows:

  ```elixir
    live_term "/", MyBackend, pubsub_server: MyAppWeb.PubSub, css: {:priv, my_app, "path/to/my_layout.css"}
  ```

  Note that this content must be available at compile time.
  """

  alias ExTerm.Console
  alias ExTerm.Console.Cell
  alias ExTerm.Console.Helpers
  alias ExTerm.Console.Update
  alias ExTerm.Style
  alias Phoenix.LiveView.JS

  require Console
  require Helpers

  use Phoenix.LiveView

  @doc false
  def render(assigns) do
    ~H"""
    <div id="exterm-terminal" contenteditable spellcheck="false" class={class_for(@focus)} phx-keydown="keydown" phx-focus="focus" phx-blur="blur" tabindex="0">
      <Console.render :if={@console} cells={@cells} cursor={@cursor} prompt={@prompt}/>
      <div :if={@console} id="exterm-anchor" phx-mounted={JS.dispatch("exterm:mounted", to: "#exterm-terminal")}/>
    </div>
    <div id="exterm-paste-target" phx-click="paste"/>

    <ExTerm.JS.render/>
    <ExTerm.CSS.render css={@css}/>
    """
  end

  defp class_for(focus) do
    case focus do
      true -> ~w"exterm exterm-focused"
      false -> ~w"exterm exterm-blurred"
      :error -> ~w"exterm exterm-errored"
    end
  end

  @doc false
  def mount(params, session = %{"exterm-backend" => {backend, opts}}, socket) do
    css = Keyword.fetch!(opts, :css)

    if connected?(socket) do
      case backend.on_connect(params, session, socket) do
        {:ok, console, socket} ->
          # obtain the layout and dump the whole layout.
          {rows, columns} =
            Helpers.transaction console, :access do
              Console.layout(console)
            end

          # fill the cells with dummy cells that won't be in the initial layout.
          sentinel_column = columns + 1

          cells =
            for row <- 1..rows, column <- 1..sentinel_column do
              {{row, column}, %Cell{char: if(column === sentinel_column, do: "\n")}}
            end

          new_socket =
            socket
            |> init(css, console)
            |> assign(backend: backend, cells: cells)

          {:ok, new_socket, temporary_assigns: [cells: []]}
      end
    else
      {:ok, init(socket, css), temporary_assigns: [cells: []]}
    end
  end

  # reducers
  defp init(socket, css, console \\ nil) do
    socket
    |> assign(:css, css)
    |> set_cursor
    |> set_focus
    |> set_prompt
    |> set_console(console)
  end

  defp set_cursor(socket, cursor \\ {1, 1}) do
    assign(socket, cursor: cursor)
  end

  defp set_focus(socket, focus \\ false) do
    assign(socket, focus: focus)
  end

  defp set_prompt(socket, prompt \\ false)

  defp set_prompt(socket = %{assigns: %{console: console, cursor: cursor}}, prompt) do
    cell =
      Helpers.transaction console, :access do
        Console.get(console, cursor)
      end

    assign(socket, prompt: prompt, cells: [{cursor, cell}])
  end

  defp set_prompt(socket, false) do
    assign(socket, prompt: false)
  end

  defp set_console(socket, console) do
    assign(socket, console: console)
  end

  # handlers

  @doc false
  def handle_event("focus", _payload, socket) do
    case socket.assigns.focus do
      false ->
        socket
        |> set_focus(true)
        |> socket.assigns.backend.on_focus()

      _ ->
        {:noreply, socket}
    end
  end

  def handle_event("blur", _payload, socket) do
    case socket.assigns.focus do
      true ->
        socket
        |> set_focus(false)
        |> socket.assigns.backend.on_blur()

      _ ->
        {:noreply, socket}
    end
  end

  def handle_event("keydown", %{"key" => key}, socket) do
    socket.assigns.backend.on_keydown(key, socket)
  end

  def handle_event("keyup", %{"key" => key}, socket) do
    socket.assigns.backend.on_keyup(key, socket)
  end

  def handle_event("paste", %{"paste" => string}, socket) do
    socket.assigns.backend.on_paste(string, socket)
  end

  def handle_event(type, payload, socket = %{assigns: %{backend: backend}}) do
    if function_exported?(backend, :on_event, 3) do
      socket.assigns.backend.on_event(type, payload, socket)
    else
      {:noreply, socket}
    end
  end

  @doc false

  def handle_info(update = %Update{}, socket = %{assigns: %{console: console}}) do
    cells =
      Helpers.transaction console, :access do
        Update.get(update, console)
      end

    new_socket =
      if cursor = update.cursor do
        assign(socket, cells: cells, cursor: cursor)
      else
        assign(socket, cells: cells)
      end

    {:noreply, new_socket}
  end

  def handle_info({:prompt, activity}, socket) when activity in [:active, :inactive] do
    {:noreply, set_prompt(socket, activity === :active)}
  end

  def handle_info({:DOWN, _, :process, _, {err, stacktrace}}, socket) do
    message = "fatal error crashed the console\n" <> Exception.format(:error, err, stacktrace)
    {row, _} = socket.assigns.cursor
    style = %Style{color: :red, "white-space": :pre, "overflow-anchor": :auto}

    {:noreply,
     assign(socket, focus: :error, cells: [{{row + 1, 1}, %Cell{style: style, char: message}}])}
  end
end