Rendering tasks (Writing TUI with Ratatouille)
Tags: elixir, tui, terminal, tutorial
Published: 2023-06-27 21:28:48

**Previously on Writing TUI with Ratatouille**: we learned how to add Ratatouille to the project, what are the main building blocks of its UI and we also created a very simple layout with three panels.

Today we will actually add some tasks and we will use keyboard arrows to iterate over them.

## The Database

Before we go, though, we need to write some code that will server as our database of the tasks. Initially, when I wrote the first version, I used the `Agent` to keep the data in the separate process, but in fact this is not necessary here. We will use just a simple struct as out tasks database:

```elixir
defmodule Todox.Database do
  @moduledoc false

  defmodule Task do
    defstruct [:name, :finished?]
  end

  def new, do: []

  def all(tasks), do: tasks

  def add_task(tasks, name) do
    task = %Task{name: name, finished?: false}
    tasks ++ [task]
  end

  def get(tasks, index) do
    Enum.at(tasks, index)
  end

  def size(tasks), do: length(tasks)
end
```

As you can see, this is very simple - just creates a simple interface to initialize the database, return all tasks, add a new one, fetch the one by an index and return the number of tasks in the database. Good news is that we can now add some tests to it!

```elixir
defmodule Todox.DatabaseTest do
  use ExUnit.Case, async: true

  alias Todox.Database

  describe "add_task/2" do
    test "add a new task to an empty database" do
      assert [task] =
               Database.new()
               |> Database.add_task("Feed the cat")
               |> Database.all()

      assert task.name == "Feed the cat"
      assert task.finished? == false
    end
  end

  describe "get/2" do
    test "fetch task by its index" do
      db =
        Database.new()
        |> Database.add_task("Feed the cat")
        |> Database.add_task("Buy milk")

      assert Database.get(db, 0).name == "Feed the cat"
      assert Database.get(db, 1).name == "Buy milk"
    end
  end

  describe "size/1" do
    test "return size equal to number of added tasks" do
      db =
        Database.new()
        |> Database.add_task("Feed the cat")
        |> Database.add_task("Byu milk")

      assert Database.size(db) == 2
    end
  end
end
```

Before we run them, let's alter our `Todox.Application` a bit to not start the UI in the test env. We don't want this, as this will bork our terminal output when running `mix test`.

To do that, we will change how we define the `children` variable to:

```elixir
children =
  if Mix.env() == :test do
    []
  else
    [{Ratatouille.Runtime.Supervisor, runtime: ratatouille_opts}]
  end
```

Now we can run the tests to verify things work:

```
$ mix test 
...
Finished in 0.05 seconds (0.05s async, 0.00s sync)
3 tests, 0 failures

Randomized with seed 915171
```

Good! We will now plug the database to our application.

## Rendering the tasks

The first step here is to alter our `init` function, so that it initializes the database. The code will be similar to what we've seen in the tests:

```elixir
def init(%{window: window}) do
  database =
    Database.new()
    |> Database.add_task("Feed the cat")
    |> Database.add_task("Buy milk")
    |> Database.add_task("Write part 3 of the tutorial")

  %{window: window, database: database}
end
```

Now, wherever `update` or `render` is called, we will have the database at our disposal.

We will use it to render the list of the tasks in the left panel:

```elixir
def render(model) do
  view do
    row do
      column(size: 6) do
        panel(title: "Tasks", height: :fill) do
          table do
            for task <- Database.all(model.database) do
              table_row do
                table_cell(
                  content: "[#{if task.finished?, do: "✓", else: " "}] " <> task.name
                )
              end
            end
          end
        end
      end
# rest of the function
```

Inside our "Tasks" panel we are rendering a `table` component. Perhaps it does not necessarily need to be a table, because it just has one column, but just in case we want to add more in the future... We iterate over `Database.all/1` results and render a row for each task. This includes a simple finished-or-not "widget" and the task name. The final result should look like this:

![Screenshot of our application with two right panels empty and the right one listing our defined tasks](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1rbq0onvuneae4uucr8j.png)

## Highlighting the task

Now we want to be able to use the keyboard to move around the tasks. To do so, we need to add a `cursor` to our model.

```elixir
def init(%{window: window}) do
  database =
    Database.new()
    |> Database.add_task("Feed the cat")
    |> Database.add_task("Buy milk")
    |> Database.add_task("Write part 3 of the tutorial")

  %{window: window, database: database, cursor: 0}
end
```

In the `render` function, we will check which task is highlighted and we will display it a bit differently.

```elixir
def render(model) do
  highlighted_task = Database.get(model.database, model.cursor)

  view do
    row do
      column(size: 6) do
        panel(title: "Tasks", height: :fill) do
          table do
            for task <- Database.all(model.database) do
              table_row(if task == highlighted_task, do: @style_row_selected, else: []) do
                table_cell(
                  content: "[#{if task.finished?, do: "✓", else: " "}] " <> task.name
                )
              end
            end
          end
        end
      end
```

Few things happened here. First, we save a `highlighted_task` value using a database and the cursor value.

Second, we added a style to the `table_row`. A style is a keyword list with some values defined by Ratatouille. In this case, if the task equals the highlighted task, we add the `@style_row_selected` style and an empty list otherwise.

The one thing left is to define the style:

```elixir
import Ratatouille.Constants, only: [color: 1]

@style_row_selected [
  color: color(:black),
  background: color(:white)
]
```

And now, it looks like the first task is indeed highlighted.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n802h2veld7fzpdjl8jx.png)

## I like to move it, move it!

Last thing we'd like to do today is to be able to highlight next or previous task with arrow keys. For the first time we will actually do something in our `update` function. Feeling excited already?

Before we do that, we need to define the keys using `Ratatouille.Constants`. Yes, it's the same thing that we used for colors, but now we also need its `key` function. So we need to adjust the `import` statement:

```elixir
import Ratatouille.Constants, only: [color: 1, key: 1]
```

And now, define some keys:

```elixir
@arrow_up key(:arrow_up)
@arrow_down key(:arrow_down)
```

Let's go to our update function now.

As you may recall, `update` accepts two arguments. The first one it the current model of the application and the second is the message or the event. We will pattern-match on the event and alter the model in response:

```elixir
def update(model, message) do
  case {model, message} do
    {_, {:event, %{key: key}}} when key == @arrow_down ->
      if model.cursor < Database.size(model.database) - 1,
        do: %{model | cursor: model.cursor + 1},
        else: model

    {_, {:event, %{key: key}}} when key == @arrow_up ->
      if model.cursor > 0,
        do: %{model | cursor: model.cursor - 1},
        else: model

    _ ->
      model
  end
end
```

This piece of code might seem a little dense, but we aren't doing anything really special here. We match using `case` on `{model, message}` tuple - and you might wonder why not just on the message. The answer is that we will need to check some things on the model in the future too, and it's good to be prepared for that.

Then comes the message itself. It's a tuple with `:event` as the first element and the map as the second. In the map we look are the `key` key and it's value. If it matches arrow down, we try to add 1 to the cursor, if it does not go beyond boundaries. Similarly, we decrement the cursor on arrow down (but not below zero).

Of course, we also need to handle any other key press and do nothing.

Perhaps surprisingly, we can just test it like any other function:

```elixir
defmodule TodoxUpdateTest do
  use ExUnit.Case, async: true

  alias Todox.Database

  import Ratatouille.Constants, only: [key: 1]

  @arrow_up_event {:event, %{key: key(:arrow_up)}}
  @arrow_down_event {:event, %{key: key(:arrow_down)}}

  @database Database.new()
            |> Database.add_task("Feed the cat")
            |> Database.add_task("Buy milk")

  @model %{database: @database, cursor: 0}

  describe "arrow down" do
    test "increment the cursor" do
      model = Todox.update(@model, @arrow_down_event)
      assert model.cursor == 1
    end

    test "don't go beyond boundary" do
      model =
        %{@model | cursor: 1}
        |> Todox.update(@arrow_down_event)

      assert model.cursor == 1
    end
  end
end
```

(and very similar for arrow up)

Our final result looks like this now:

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

And that's it - this is what we wanted to achieve in this part. In the next one we will learn how to add a new task. And, SPOILER ALERT, it won't be so trivial.

Cheers!