Rendering tasks (Writing TUI with Ratatouille)

Paweł Świątkowski - Jun 27 '23 - - Dev Community

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:



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


Enter fullscreen mode Exit fullscreen mode

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!



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


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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

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.



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


Enter fullscreen mode Exit fullscreen mode

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



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


Enter fullscreen mode Exit fullscreen mode

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:



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

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


Enter fullscreen mode Exit fullscreen mode

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

Image description

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:



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


Enter fullscreen mode Exit fullscreen mode

And now, define some keys:



@arrow_up key(:arrow_up)
@arrow_down key(:arrow_down)


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

(and very similar for arrow up)

Our final result looks like this now:

Image description

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!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .