Skip to content

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.

aivi
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.

aivi
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 state

The 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.

aivi
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 _ => ClearDone

This 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.

aivi
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
  |> footer

This 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.

aivi
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 main

Step 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:

sh
target/debug/aivi check tasks.aivi
target/debug/aivi run tasks.aivi

Add 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 ​

ConceptWhere it appears
Closed typesFilter and Event make the app vocabulary explicit
Plain recordsTodo and State hold application data
Pure functionsstep, addItem, toggleItem, visibleTodos
Signalsevent, state, draftText, visibleItems, canAdd, footerText
Merge and accumulationUI events merge into event; +|> folds them into state
GTK markupEntry, Button, Box, Label, and <each>
Functional style without ceremonyNo mutable variables, no callback soup, no unnecessary memo names

The data flow ​

text
Entry + buttons  →  UI event signals
                        ↓
               merged into Event values
                        ↓
                 +|> folds into State
                        ↓
            derived signals feed the GTK tree

Every 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

(c) 2026 by Andreas Herd