Writing TUI with Ratatouille

katafrakt

Paweł Świątkowski

Posted on June 25, 2023

Writing TUI with Ratatouille

(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.

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 - an Elixir toolkit for writing TUI (Terminal UI), based on termbox.

This was the result:

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.


Enter fullscreen mode Exit fullscreen mode

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



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


Enter fullscreen mode Exit fullscreen mode

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



  @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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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



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


Enter fullscreen mode Exit fullscreen mode

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:

To do so, we mostly need to change our render function. Ratatouille operates on columns and rows, 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:



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


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
katafrakt
Paweł Świątkowski

Posted on June 25, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related