(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.
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
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
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:
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 usenil
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 fromRatatouille.View
for that. In our example, this is an emptyview
, 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 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:
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:
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:
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