Writing TUI with Ratatouille
Tags: elixir, tui, terminal, tutorial
Published: 2023-06-25 14:17:04

(the title should rhyme, if it does not perhaps you're not reading it correctly... or I'm really bad at rhyming)

Few days ago Sean Moriarity announced a mini-challenge to write a simple TODO app in Elixir.

{% twitter 1671231157910765586 %}

Most answers were a code-golf style with writing the full functionality in least number of lines of code. I took a different approach. Since some time already I wanted to try out [Ratatouille](https://github.com/ndreynolds/ratatouille) - an Elixir toolkit for writing TUI (Terminal UI), based on [termbox](https://github.com/nsf/termbox).

This was the result:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hmq901a7sdlavqknxaep.gif)

In this post (or rather post series) I'm going to retrace my steps, but in a more organized way. It was the first time I used Ratatouille and I wanted to code it really quick. This time I'll try to be more thorough, with testing and everything.

Let's start!

## Getting started

To get started, we need to create a new Mix project with a supervisor:

```
> mix new todox --sup

* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating lib
* creating lib/todox.ex
* creating lib/todox/application.ex
* creating test
* creating test/test_helper.exs
* creating test/todox_test.exs

Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:

    cd todox
    mix test

Run "mix help" for more commands.
```

Then we need to add `ratatouille` to the dependencies in `mix.exs` and fetch it with `mix deps.get`:

```elixir
  defp deps do
    [
      {:ratatouille, "~> 0.5.1"}
    ]
  end
```

In `lib/todox/application.ex` we need to add `ratatouille` to our supervision tree, by modifying the `start` function:

```elixir
  @impl true
  def start(_type, _args) do
    ratatouille_opts = [
      app: Todox,
      shutdown: {:application, :todox}
    ]

    children = [
      {Ratatouille.Runtime.Supervisor, runtime: ratatouille_opts}
    ]

    opts = [strategy: :one_for_one, name: Todox.Sup
ervisor]
    Supervisor.start_link(children, opts)
  end
```

If we would try to start the application now with `mix run --no-halt`, it will error out, complaining about missing function, that it expected to find:

```
20:52:08.450 [error] Error in application loop:
  ** (UndefinedFunctionError) function Todox.init/1 is undefined or private
```

We need to go to our `lib/todox.ex` and prepare it to be a Ratatouille app:

```elixir
defmodule Todox do
  @behaviour Ratatouille.App

  import Ratatouille.View

  @impl true
  def init(_opts), do: %{}

  @impl true
  def update(model, _message), do: model

  @impl true
  def render(_model) do
    view do
    end
  end
end
```

Before we dive in to that, let's just quickly check the results. After having run `mix run --no-halt` you should see your terminal replaced with a completely blank screen. This actually means the success. Quit by pressing `q` or `ctrl+c`.

Now back to our code. We have defined three functions defined as callbacks in `Ratatouille.App` behaviour. I'll briefly explain that they do:

* `init`, as the name suggests, is called upon the application starts. It takes some options as input, about which we will care in a little while. The return value is a **model**, which basically contains the state of the application. In this case we don't use any state, so we just return a empty map, although we could use `nil` as well.
* Whenever something _happens_, for example a key is pressed (we will only handle this kind of events), an `update` method is invoked. It takes current model and a **message** (event), and it returns updated model. Here we don't handle anything in particular, so we just return unchanged model.
* `render` is a functions that gets called after each update to the model. It defines what we should draw in the screen. We use functions imported from `Ratatouille.View` for that. In our example, this is an empty `view`, which just maps to a blank screen.

## Adding a layout

To finish this part with something more exciting than just a blank screen, we will replicate the screen layout I had in my app:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4d4jygsnm6aqeng1edly.png)

To do so, we mostly need to change our `render` function. Ratatouille operates on `column`s and `row`s, we will also use a `panel` to have this rectangle with a border and a heading text. All in all, the render function looks like this:

```elixir
def render(model) do
  view do
    row do
      column(size: 6) do
        panel(title: "Tasks", height: :fill) do
        end
      end

      column(size: 6) do
        row do
          column(size: 12) do
            panel(title: "Details", height: model.window.height - 10) do
            end
          end
        end

        row do
          column(size: 12) do
            panel(title: "Help", height: 10)
          end
        end
      end
    end
  end
end
```

We define one row with two columns. Similarly to Booststap, Ratatouille uses 12-columns grid. Setting the width of both columns to 6 makes them share a half of the view each. The first column contains just a simple panel definition:

```elixir
panel(title: "Tasks", height: :fill) do
end
```

This renders a panel, sets a title and tells it to fill the whole view height.

The right column is a bit more complicated. We need to have two rows inside, each of them has a full-width column - and in that column there is a panel.

Something different happens to panels' height though. The first, larger panel has `height: model.window.height - 10`, the second: `height: 10`. As you might have guessed, the `model.window.height` value should contain the height of the window. But how does it get there?

The difference lies in the `init` function definition. If you recall, I said that this function accepts _some_ options and returns the initial model value. These options to `init` contain the `window` struct we use - it's all handled by Ratatouille. So, to make things work, we need to alter our `init` function to:

```elixir
def init(%{window: window}), do: %{window: window}
```

With all that in place, we should be able to `run mix --no-halt` and see a screen similar to what I pasted above. Good job!

In the next part we will display some todo items in the left panel and implement highlighting "current todo" with the keyboard.

---

The code from this part is published in a git repository under `part-01` tag: https://codeberg.org/katafrakt/todox/src/tag/part-01