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
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
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
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:
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:
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:
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
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
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)
]
And now, it looks like the first task is indeed highlighted.
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]
And now, define some keys:
@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:
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:
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:
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!