Under the Hood

Ratatouille's runtime wraps up most of the logic concerning the application loop, update the terminal window, and subscribing to and delegating events. It's convenient to let the runtime worry about these details, and it allows us to write apps more declaratively by defining them in terms of callbacks---similar to how we implement OTP behaviours like gen_server and supervisor.

In some cases, it may be necessary to control these details. This guide explains how you can use the window, event manager, receive and some recursion to manually define an application loop.

Hello World from Scratch

Let's build a hello world application. It'll display "Hello World" and quit when the "q" key is pressed. First we'll look at the entire example, then we'll go through it line by line to see what each line does. You can also find this example in the repo and run it with mix run.

# examples/hello_world.exs

alias Ratatouille.{EventManager, Window}

import Ratatouille.View

{:ok, _pid} = Window.start_link()
{:ok, _pid} = EventManager.start_link()
:ok = EventManager.subscribe(self())

hello_world_view =
  view do
    panel title: "Hello, World!", height: :fill do
      label(content: "Press 'q' to quit.")
    end
  end

:ok = Window.update(hello_world_view)

receive do
  {:event, %{ch: ?q}} ->
    :ok = Window.close()
end

First, some aliases for the modules we'll use:

alias Ratatouille.{EventManager, Window}

Next, we import the View DSL from Ratatouille.View:

import Ratatouille.View

The View DSL provides element builder functions like view, row, table, label that you can use to define views. Think of them like HTML tags.

Now we'll initialize the application using Ratatouille.Window. This is a gen_server that manages our terminal window and exposes a basic API for accessing information about or updating the terminal window. On init, it draws a blank canvas over the terminal:

{:ok, _pid} = Window.start_link()

In a real project, you'll usually want to use an OTP application with a proper supervision tree, but here we'll keep it as simple as possible and start our processes manually.

In order to react to keyboard, click or resize events, we'll use Ratatouille.EventManager. The event manager allows processes to subscribe to events and then send its subscribers a message whenever an event is triggered. We need to start the event manager and subscribe the current process to any events:

{:ok, _pid} = EventManager.start_link()
:ok = EventManager.subscribe(self())

Next, we define a view. Similar to HTML, a view is defined as a tree of nodes. Nodes have attributes (e.g., text: bold) and children (nested content). Every view must start with a root view element---it's sort of like the <body> tag in HTML.

hello_world_view =
  view do
    panel title: "Hello, World!", height: :fill do
      label(content: "Press 'q' to quit.")
    end
  end

Defining a view only does just that. To render it to the screen, we need to call the Window.update/1 function, passing our view as the argument.

:ok = Window.update(hello_world_view)

When a key is pressed, it'll be sent to us by the event manager. Once we receive a 'q' key press, we'll close the application. Here, we use the built-in receive function with pattern-matching in order to match only the 'q' key press event:

receive do
  {:event, %{ch: ?q}} ->
    :ok = Window.close()
end

That's it---now you can run the program with mix run <file>. To run the bundled example:

$ mix run examples/hello_world.exs

You should see the content we created and be able to quit using 'q'.

Application Loops

While the previous example illustrated the basics, it's unfortunately not a very useful application on its own. Useful terminal applications need to update the view based on events or on a given interval (e.g., every second). They may also need to hold state such as the cursor position or selected tab, and fetch data from local sources or via the network.

Rendering on an interval

This time we'll build a clock application to show how intervals can be achieved. It will display the current time and update each second.

Be careful trying this one out, as we don't provide a way to quit yet---you'll need to kill the process (for example, by closing your terminal window).

defmodule Clock do
  alias Ratatouille.Window

  import Ratatouille.View

  def start do
    {:ok, _pid} = Window.start_link()
    loop()
  end

  def loop do
    clock_view = render(DateTime.utc_now())
    Window.update(clock_view)
    Process.sleep(1_000)
    loop()
  end

  def render(now) do
    view do
      panel title: "Clock Example" do
        label(content: "The time is: " <> DateTime.to_string(now))
      end
    end
  end
end

Clock.start()

There are a few things of note here.

We've defined this application in a module. This is how you'll usually want to do it.

We've defined a start/1 function to do the initial setup.

That setup calls the loop/0 function, which is our application loop. Each loop renders a view, updates the window, waits one second and then calls itself to start the process all over again.

The view is built via a render/1 function. If you're familiar with React.js, the idea is similar. Our "render" functions should always be pure functions of their state--any two calls with the same arguments should always have the same result. As such, we also pass in the state here: the current time.

Lastly, we remember to actually start this thing with Clock.start().

Combining an interval with event handling

In the clock example, we now have a working update interval, but no way to handle events---and therefore, no way to quit the application. Let's fix that:

defmodule Clock do
  alias Ratatouille.{EventManager, Window}

  import Ratatouille.View

  def start do
    {:ok, _pid} = Window.start_link()
    {:ok, _pid} = EventManager.start_link()
    :ok = EventManager.subscribe(self())
    loop()
  end

  def loop do
    clock_view = render(DateTime.utc_now())
    Window.update(clock_view)

    receive do
      {:event, %{ch: ?q}} ->
        :ok = Window.close()
    after
      1_000 ->
        loop()
    end
  end

  def render(now) do
    view do
      panel title: "Clock Example ('q' to quit)" do
        label(content: "The time is: " <> DateTime.to_string(now))
      end
    end
  end
end

Clock.start()

In the new version, we fire up the event manager and subscribe ourself to any events that come through.

Then we use a receive like in the hello world application, and pair that with an after to achieve the interval.

Instead of sleeping, our loop now has a new job: wait for events for 1 second, then start over (updating and re-rendering the clock).

Try it out yourself:

mix run examples/clock.exs

Holding on to state

In the clock example, we were working with externally defined state that we retrieved in each loop with some help from Elixir and the BEAM.

But what if we need to hold on to state across loops?

As a concrete example, imagine we want to hold on to the cursor position. Maybe this is a text editor. This is possible by storing our state within the application loop itself (not unlike how a gen_server works under the hood):

def start do
  # ...

  loop(0) # initial cursor
end

def loop(cursor) do
  # ...

  receive do
    {:event, %{ch: @arrow_down}} ->
      loop(cursor + 1)

    {:event, %{ch: @arrow_up}} ->
      loop(cursor - 1)
  after
    1_000 ->
      loop(cursor)
  end
end