Purely Functional GTK+, Part 2: TodoMVC
In the last episode we built a “Hello, World” application using gi-gtk-declarative. It’s now time to convert it into a to-do list application, in the style of TodoMVC.
To convert the “Hello, World!” application to a to-do list application, we begin by adjusting our data types. The Todo
data type represents a single item, with a Text
field for its name. We also need to import the Text
type from Data.Text
.
data Todo = Todo
{ name :: Text
}
Our state will no longer be ()
, but a data types holding Vector
of Todo
items. This means we also need to import Vector
from Data.Vector
.
data State = State
{ todos :: Vector Todo
}
As the run
function returns the last state value of the state reducer loop, we need to discard that return value in main
. We wrap the run
action in void
, imported from Control.Monad
.
Let’s rewrite our view function. We change the title to “TodoGTK+” and replace the label with a todoList
, which we’ll define in a where
binding. We use container
to declare a Gtk.Box
, with vertical orientation, containing all the to-do items. Using fmap
and a typed hole, we see that we need a function Todo -> BoxChild Event
.
view' :: State -> AppView Gtk.Window Event
view' s = bin
Gtk.Window
[#title := "TodoGTK+", on #deleteEvent (const (True, Closed))]
todoList
where
todoList = container Gtk.Box
[#orientation := Gtk.OrientationVertical]
(fmap _ (todos s))
The todoItem
will render a Todo
value as a Gtk.Label
displaying the name.
view' :: State -> AppView Gtk.Window Event
view' s = bin
Gtk.Window
[#title := "TodoGTK+", on #deleteEvent (const (True, Closed))]
todoList
where
todoList = container Gtk.Box
[#orientation := Gtk.OrientationVertical]
(fmap todoItem (todos s))
todoItem todo = widget Gtk.Label [#label := name todo]
Now, GHC tells us there’s a “non-type variable argument in the constraint”. The type of todoList
requires us to add the FlexibleContexts
language extension.
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
module Main where
The remaining type error is in the definition of main
, where the initial state cannot be a ()
value. We construct a State
value with an empty vector.
main :: IO ()
main = void $ run App
{ view = view'
, update = update'
, inputs = []
, initialState = State {todos = mempty}
}
Adding New To-Do Items
While our application type-checks and runs, there are no to-do items to display, and there’s no way of adding new ones. We need to implement a form, where the user inserts text and hits the Enter key to add a new to-do item. To represent these events, we’ll add two new constructors to our Event
type.
data Event
= TodoTextChanged Text
| TodoSubmitted
| Closed
TodoTextChanged
will be emitted each time the text in the form changes, carrying the current text value. The TodoSubmitted
event will be emitted when the user hits Enter.
When the to-do item is submitted, we need to know the current text to use, so we add a currentText
field to the state type.
data State = State
{ todos :: Vector Todo
, currentText :: Text
}
We modify the initialState
value to include an empty Text
value.
main :: IO ()
main = void $ run App
{ view = view'
, update = update'
, inputs = []
, initialState = State {todos = mempty, currentText = mempty}
}
Now, let’s add the form. We wrap our todoList
in a vertical box, containing the todoList
and a newTodoForm
widget.
view' :: State -> AppView Gtk.Window Event
view' s = bin
Gtk.Window
[#title := "TodoGTK+", on #deleteEvent (const (True, Closed))]
(container Gtk.Box
[#orientation := Gtk.OrientationVertical]
[todoList, newTodoForm]
)
where
...
The form consists of a Gtk.Entry
widget, with the currentText
of our state as its text
value. The placeholder text will be shown when the entry isn’t focused. We use onM
to attach an effectful event handler to the changed signal.
view' :: State -> AppView Gtk.Window Event
view' s = bin
Gtk.Window
[#title := "TodoGTK+", on #deleteEvent (const (True, Closed))]
(container Gtk.Box
[#orientation := Gtk.OrientationVertical]
[todoList, newTodoForm]
)
where
...
newTodoForm = widget
Gtk.Entry
[ #text := currentText s
, #placeholderText := "What needs to be done?"
, onM #changed _
]
The typed hole tells us we need a function Gtk.Entry -> IO Event
. The reason we use onM
is to have that IO
action returning the event, instead of having a pure function. We need it to query the underlying GTK+ widget for it’s current text value. By using entryGetText
, and mapping our event constructor over that IO action, we get a function of the correct type.
...
newTodoForm = widget
Gtk.Entry
[ #text := currentText s
, #placeholderText := "What needs to be done?"
, onM #changed (fmap TodoTextChanged . Gtk.entryGetText)
]
It is often necessary to use onM
and effectful GTK+ operations in event handlers, as the callback type signatures rarely have enough information in their arguments. But for the next event, TodoSubmitted
, we don’t need any more information, and we can use on
to declare a pure event handler for the activated signal.
...
newTodoForm = widget
Gtk.Entry
[ #text := currentText s
, #placeholderText := "What needs to be done?"
, onM #changed (fmap TodoTextChanged . Gtk.entryGetText)
, on #activate TodoSubmitted
]
Moving to the next warning, we see that the update'
function is no longer total. We are missing cases for our new events. Let’s give the arguments names and pattern match on the event. The case for Closed
will be the same as before.
update' :: State -> Event -> Transition State Event
update' s e = case e of
Closed -> Exit
When the to-do text value changes, we’ll update the currentText
state using a Transition
. The first argument is the new state, and the second argument is an action of type IO (Maybe Event)
. We don’t want to emit any new event, so we use (pure Nothing)
.
update' :: State -> Event -> Transition State Event
update' s e = case e of
TodoTextChanged t -> Transition s { currentText = t } (pure Nothing)
Closed -> Exit
For the TodoSubmitted
event, we define a newTodo
value with the currentText
as its name, and transition to a new state with the newTodo
item appended to the todos
vector. We also reset the currentText
to be empty.
To use Vector.snoc
, we need to add a qualified import.
import Control.Monad (void)
import Data.Text (Text)
import Data.Vector (Vector)
import qualified Data.Vector as Vector
import qualified GI.Gtk as Gtk
import GI.Gtk.Declarative
import GI.Gtk.Declarative.App.Simple
Running the application, we can start adding to-do items.
Improving the Layout
Our application doesn’t look very good yet, so let’s improve the layout a bit. We’ll begin by left-aligning the to-do items.
todoItem i todo =
widget
Gtk.Label
[#label := name todo, #halign := Gtk.AlignStart]
To push the form down to the bottom of the window, we’ll wrap the todoList
in a BoxChild
, and override the defaultBoxChildProperties
to have the child widget expand and fill all the available space of the box.
todoList =
BoxChild defaultBoxChildProperties { expand = True, fill = True }
$ container Gtk.Box
[#orientation := Gtk.OrientationVertical]
(fmap todoItem (todos s))
We re-run the application, and see it has a nicer layout.
Completing To-Do Items
There’s one very important missing: being able to mark a to-do item as completed. We add a Bool
field called completed
to the Todo
data type.
data Todo = Todo
{ name :: Text
, completed :: Bool
}
When creating new items, we set it to False
.
update' :: State -> Event -> Transition State Event
update' s e = case e of
...
TodoSubmitted ->
let newTodo = Todo {name = currentText s, completed = False}
in Transition
s { todos = todos s `Vector.snoc` newTodo, currentText = mempty }
(pure Nothing)
...
Instead of simply rendering the name, we’ll use strike-through markup if the item is completed. We define completedMarkup
, and using guards we’ll either render the new markup or render the plain name. To make it strike-through, we wrap the text value in <s>
tags.
widget
Gtk.Label
[ #label := completedMarkup todo
, #halign := Gtk.AlignStart
]
where
completedMarkup todo
| completed todo = "<s>" <> name todo <> "</s>"
| otherwise = name todo
For this to work, we need to enable markup for the label be setting #useMarkup
to True
.
widget
Gtk.Label
[ #label := completedMarkup todo
, #useMarkup := True
, #halign := Gtk.AlignStart
]
where
completedMarkup todo
| completed todo = "<s>" <> name todo <> "</s>"
| otherwise = name todo
In order for the user to be able to toggle the completed status, we wrap the label in a Gtk.CheckButton
bin. The #active
property will be set to the current completed status of the Todo
value. When the check button is toggled, we want to emit a new event called TodoToggled
.
todoItem todo =
bin Gtk.CheckButton
[#active := completed todo, on #toggled (TodoToggled i)]
$ widget
Gtk.Label
[ #label := completedMarkup todo
, #useMarkup := True
, #halign := Gtk.AlignStart
]
Let’s add the new constructor to the Event
data type. It will carry the index of the to-do item.
data Event
= TodoTextChanged Text
| TodoSubmitted
| TodoToggled Int
| Closed
To get the corresponding index of each Todo
value, we’ll iterate using Vector.imap
instead of using fmap
.
todoList =
BoxChild defaultBoxChildProperties { expand = True, fill = True }
$ container Gtk.Box
[#orientation := Gtk.OrientationVertical]
(Vector.imap todoItem (todos s))
todoItem i todo =
...
The pattern match on events in the update'
function is now missing a case for the new event constructor. Again, we’ll do a transition where we update the todos
somehow.
update' :: State -> Event -> Transition State Event
update' s e = case e of
...
TodoToggled i -> Transition s { todos = _ (todos s) } (pure Nothing)
...
We need a function Vector Todo -> Vector Todo
that modifies the value at the index i
. There’s no handy function like that available in the vector
package, so we’ll create our own. Let’s call it mapAt
.
update' :: State -> Event -> Transition State Event
update' s e = case e of
...
TodoToggled i -> Transition s { todos = mapAt i _ (todos s) } (pure Nothing)
...
It will take as arguments the index, a mapping function, and a Vector a
, and return a Vector a
.
mapAt :: Int -> (a -> a) -> Vector a -> Vector a
We implement it using Vector.modify
, and actions on the mutable representation of the vector. We overwrite the value at i
with the result of mapping f
over the existing value at i
.
mapAt :: Int -> (a -> a) -> Vector a -> Vector a
mapAt i f = Vector.modify (\v -> MVector.write v i . f =<< MVector.read v i)
To use mutable vector operations through the MVector
name, we add the qualified import.
import qualified Data.Vector.Mutable as MVector
Finally, we implement the function to map, called toggleComplete
.
toggleCompleted :: Todo -> Todo
toggleCompleted todo = todo { completed = not (completed todo) }
update' :: State -> Event -> Transition State Event
update' s e = case e of
...
TodoToggled i -> Transition s { todos = mapAt i toggleComplete (todos s) } (pure Nothing)
...
Now, we run our application, add some to-do items, and mark or unmark them as completed. We’re done!
Learning More
Building our to-do list application, we have learned the basics of gi-gtk-declarative
and the “App.Simple” architecture. There’s more to learn, though, and I recommend checking out the project documentation. There are also a bunch of examples in the Git repository.
Please note that this project is very young, and that APIs are not necessarily stable yet. I think, however, that it’s a much nicer way to build GTK+ applications using Haskell than the underlying APIs provided by the auto-generated bindings.
Now, have fun building your own functional GTK+ applications!