Build a Small Task Tracker
This tutorial walks through one complete beginner-sized AIVI app: a small GTK task tracker. By the end, you will have an app that can:
- capture text input
- add tasks to a list
- toggle tasks done or undone
- filter the list by All, Active, or Done
The point is not to build the world's fanciest task app. The point is to make the AIVI model click: types describe the data, signals carry change, pure functions describe state transitions, and the UI simply reflects the signal graph.
Step 1: Model the data first
Before building UI, define what exists in the app.
type Filter =
| All
| Active
| Done
type Todo = {
id: Int,
text: Text,
done: Bool
}
type State = {
nextId: Int,
draft: Text,
filter: Filter,
items: List Todo
}
type Event =
| DraftChanged Text
| AddTodo
| ToggleTodo Int
| SetFilter Filter
| ClearDone
value initial : State = {
nextId: 1,
draft: "",
filter: All,
items: []
}This is already a functional-programming habit worth keeping: do not start with callbacks or widget handlers. Start by making the application's shape explicit.
Step 2: Write the pure state logic
Now we describe what each event means. This is ordinary pure code: given an event and the current state, return the next state.
type Filter =
| All
| Active
| Done
type Todo = {
id: Int,
text: Text,
done: Bool
}
type State = {
nextId: Int,
draft: Text,
filter: Filter,
items: List Todo
}
type Event =
| DraftChanged Text
| AddTodo
| ToggleTodo Int
| SetFilter Filter
| ClearDone
type Todo -> Bool
func isOpen =
|> not .done
type Filter -> Todo -> Bool
func matchesFilter = current! todo
||> All -> True
||> Active -> isOpen todo
||> Done -> todo.done
type Todo -> Text
func todoLabel = todo => todo.done
T|> "[x] {todo.text}"
F|> "[ ] {todo.text}"
type Int -> Todo -> Todo
func toggleItem = target todo => todo.id == target
T|> todo <| { done: not (todo.done) }
F|> todo
type State -> State
func addItem = state => trim state.draft == ""
T|> state
F|> state <| { nextId: state.nextId + 1, draft: "", items: append state.items [{ id: state.nextId, text: trim state.draft, done: False }] }
type State -> State
func clearCompleted = state =>
state <| { items: filter isOpen state.items }
type Event -> State -> State
func step = event! state
||> DraftChanged text -> state <| { draft: text }
||> AddTodo -> addItem state
||> ToggleTodo id -> state <| { items: map (toggleItem id) state.items }
||> SetFilter current -> state <| { filter: current }
||> ClearDone -> clearCompleted stateThe step function consumes an event and the current state, then returns the next state. The signal accumulation below applies it when an event arrives.
Step 3: Turn UI events into domain events
GTK widgets can emit signal payloads directly into your reactive graph.
type Filter =
| All
| Active
| Done
type Event =
| DraftChanged Text
| AddTodo
| ToggleTodo Int
| SetFilter Filter
| ClearDone
signal draftChanged : Signal Text
signal addClick : Signal Unit
signal toggleTodo : Signal Int
signal setFilter : Signal Filter
signal clearDone : Signal Unit
signal event : Signal Event = draftChanged | addClick | toggleTodo | setFilter | clearDone
||> draftChanged text => DraftChanged text
||> addClick _ => AddTodo
||> toggleTodo id => ToggleTodo id
||> setFilter filter => SetFilter filter
||> clearDone _ => ClearDoneThis is the bridge from UI events to your app's own vocabulary. The buttons and entry do not mutate state directly; they emit values, and the signal graph folds those values into state.
Step 4: Derive the state the UI needs
Once event exists, accumulation gives us the live application state. This isolated example only handles draft and filter changes; Add, Toggle, and Clear are deliberately omitted here. The complete Step 5 example includes all handlers.
type Filter =
| All
| Active
| Done
type Todo = {
id: Int,
text: Text,
done: Bool
}
type State = {
nextId: Int,
draft: Text,
filter: Filter,
items: List Todo
}
type Event =
| DraftChanged Text
| AddTodo
| ToggleTodo Int
| SetFilter Filter
| ClearDone
value initial : State = {
nextId: 1,
draft: "",
filter: All,
items: []
}
type Todo -> Bool
func isOpen =
|> not .done
type Filter -> Todo -> Bool
func matchesFilter = current! todo
||> All -> True
||> Active -> isOpen todo
||> Done -> todo.done
type Event -> State -> State
func step = event! state
||> DraftChanged text -> state <| { draft: text }
||> AddTodo -> state
||> ToggleTodo _ -> state
||> SetFilter current -> state <| { filter: current }
||> ClearDone -> state
type State -> List Todo
func visibleTodos = state =>
filter (matchesFilter state.filter) state.items
type State -> Bool
func hasDraft = state =>
trim state.draft != ""
type State -> Text
func footer = .items
|> length
|> "{.} total tasks"
signal event : Signal Event
signal state = event
+|> initial step
signal draftText = state
|> .draft
signal visibleItems = state
|> visibleTodos
signal canAdd = state
|> hasDraft
signal footerText = state
|> footerThis is the core AIVI move: declare the dependency graph once. draftText, visibleItems, canAdd, and footerText all stay correct because they are defined from state.
Step 5: Build the UI
Now the UI is mostly straightforward. It just reads from signals and emits event payloads back into the graph.
type Filter =
| All
| Active
| Done
type Todo = {
id: Int,
text: Text,
done: Bool
}
type State = {
nextId: Int,
draft: Text,
filter: Filter,
items: List Todo
}
type Event =
| DraftChanged Text
| AddTodo
| ToggleTodo Int
| SetFilter Filter
| ClearDone
value initial : State = {
nextId: 1,
draft: "",
filter: All,
items: []
}
type Todo -> Bool
func isOpen =
|> not .done
type Filter -> Todo -> Bool
func matchesFilter = current! todo
||> All -> True
||> Active -> isOpen todo
||> Done -> todo.done
type Todo -> Text
func todoLabel = todo => todo.done
T|> "[x] {todo.text}"
F|> "[ ] {todo.text}"
type Int -> Todo -> Todo
func toggleItem = target todo => todo.id == target
T|> todo <| { done: not (todo.done) }
F|> todo
type State -> State
func addItem = state => trim state.draft == ""
T|> state
F|> state <| { nextId: state.nextId + 1, draft: "", items: append state.items [{ id: state.nextId, text: trim state.draft, done: False }] }
type State -> State
func clearCompleted = state =>
state <| { items: filter isOpen state.items }
type Event -> State -> State
func step = event! state
||> DraftChanged text -> state <| { draft: text }
||> AddTodo -> addItem state
||> ToggleTodo id -> state <| { items: map (toggleItem id) state.items }
||> SetFilter current -> state <| { filter: current }
||> ClearDone -> clearCompleted state
type State -> List Todo
func visibleTodos = state =>
filter (matchesFilter state.filter) state.items
type State -> Bool
func hasDraft = state =>
trim state.draft != ""
type State -> Text
func footer = .items
|> length
|> "{.} total tasks"
signal draftChanged : Signal Text
signal addClick : Signal Unit
signal toggleTodo : Signal Int
signal setFilter : Signal Filter
signal clearDone : Signal Unit
signal event : Signal Event = draftChanged | addClick | toggleTodo | setFilter | clearDone
||> draftChanged text => DraftChanged text
||> addClick _ => AddTodo
||> toggleTodo id => ToggleTodo id
||> setFilter filter => SetFilter filter
||> clearDone _ => ClearDone
signal state = event
+|> initial step
signal draftText = state
|> .draft
signal visibleItems = state
|> visibleTodos
signal canAdd = state
|> hasDraft
signal footerText = state
|> footer
value main =
<Window title="AIVI Task Tracker" defaultWidth={420} defaultHeight={480}>
<Box orientation="vertical" spacing={12} marginTop={16} marginBottom={16} marginStart={16} marginEnd={16}>
<Label text="Task Tracker" />
<Label text="Type a task, press Add, then click an item to toggle it done." />
<Entry text={draftText} onChange={draftChanged} />
<Button label="Add task" onClick={addClick} sensitive={canAdd} />
<Box orientation="horizontal" spacing={8}>
<Button label="All" onClick={setFilter All} />
<Button label="Active" onClick={setFilter Active} />
<Button label="Done" onClick={setFilter Done} />
<Button label="Clear done" onClick={clearDone} />
</Box>
<Box orientation="vertical" spacing={6}>
<each of={visibleItems} as={todo} key={todo.id}>
<Button label={todoLabel todo} onClick={toggleTodo todo.id} />
</each>
</Box>
<Label text={footerText} />
</Box>
</Window>
export mainStep 6: Check and run it
Build the CLI using the contributor setup and cargo build --bin aivi. Save the complete Step 5 block above as tasks.aivi at the repository root; it includes the earlier declarations, so do not concatenate the repeated blocks. From a graphical Linux desktop session, run:
target/debug/aivi check tasks.aivi
target/debug/aivi run tasks.aiviAdd two tasks, toggle one, switch between All/Active/Done, then clear completed tasks. These checks exercise event routing and rendering; type-checking alone does not test UI behavior.
What this app teaches
| Concept | Where it appears |
|---|---|
| Closed types | Filter and Event make the app vocabulary explicit |
| Plain records | Todo and State hold application data |
| Pure functions | step, addItem, toggleItem, visibleTodos |
| Signals | event, state, draftText, visibleItems, canAdd, footerText |
| Merge and accumulation | UI events merge into event; +|> folds them into state |
| GTK markup | Entry, Button, Box, Label, and <each> |
| Functional style without ceremony | No mutable variables, no callback soup, no unnecessary memo names |
The data flow
Entry + buttons → UI event signals
↓
merged into Event values
↓
+|> folds into State
↓
derived signals feed the GTK treeEvery arrow is declared in the source. There is no hidden mutation site to go hunting for later.
Next steps
- How-to Guides — move from the learning path to concrete tasks
- Signals — deeper reference for merge, accumulation, and derivation
- Markup & UI — widget catalog and control nodes
- Snake — optional bigger example once this app feels comfortable