Most programming tutorials jump straight to web apps or REST APIs. But there’s something deeply satisfying about a small, self-contained desktop tool — a thing you build in an afternoon that just works, no browser required, no server to deploy. Prove’s Graphic module makes this easy.
This post walks through building GUI apps in Prove, from a minimal counter to a functional todo list, and makes the case for why small desktop tools deserve a comeback.
The Graphic module
Prove ships two UI backends: Terminal for text-based interfaces and Graphic for windowed GUI apps. Both share the same event-driven architecture — you write a renders verb that receives events and returns the next event to process. The compiler picks the right backend based on which event type you extend.
Under the hood, Graphic uses SDL2 and Nuklear (an immediate-mode UI library). SDL2 is the only external dependency — install it with brew install sdl2 on macOS or apt install libsdl2-dev on Linux, and you’re ready to go.
The widget set is intentionally small: window, button, label, text_input, checkbox, slider, progress, and quit. No layout managers, no CSS, no build step. You call widgets in order and they stack vertically. It’s the GUI equivalent of printf debugging — simple, direct, effective.
A counter in 30 lines
Let’s start with the smallest possible GUI app: a button that increments a number.
module Counter
narrative: """
GUI counter with increment button.
renders app
"""
Graphic types GraphicAppEvent outputs window button label
Types creates string
type CounterState is
count Integer
type CounterApp is GraphicAppEvent
renders app(registered_attached_verbs List<Listens>)
event_type CounterApp
state_init CounterState(0)
from
Draw(state) =>
window("Counter", 400, 300)
label(f"Count: {string(state.count)}")
match button("Increment")
true =>
state.count = state.count + 1
Draw(state)
false => Draw(state)
Tick(state) => Draw(state)
Exit(state) => Unit
main()
from
app([])&The entire app is one renders verb. The state_init clause sets the starting state. On each Draw, we create a window, show a label with the current count, and render a button. When the button returns true (it was clicked), we increment and redraw. That’s it.
A few things to notice:
- No widget tree to manage. Widgets are function calls, not objects. Call them in order, top to bottom.
- State is explicit.
CounterStateis a plain record. Therendersverb owns it. No hidden mutable globals. - The event loop is a pattern match. Every event gets handled. The compiler ensures you don’t forget one.
Build and run:
prove build
./build/counter
You get a 400×300 window with a label and a button. Click the button, the number goes up. Close the window, the process exits cleanly.
Adding widgets: sliders and progress bars
The widget demo shows more of the palette:
module Widgets
narrative: """
GUI widget demo: slider, progress, quit.
renders app
"""
Graphic types GraphicAppEvent outputs window slider progress quit label
Types creates float string
type WidgetState is
volume Float
type WidgetApp is GraphicAppEvent
renders app(registered_attached_verbs List<Listens>)
event_type WidgetApp
state_init WidgetState(float(5))
from
Draw(state) =>
window("Widgets", 400, 300)
label(f"Vol: {string(state.volume)}")
vol as Float = slider("Vol", float(0), float(100), state.volume)
state.volume = vol
progress(50, 100)
Draw(state)
Tick(state) => Draw(state)
Exit(state) => quit()The slider call returns the current value each frame — you capture it and feed it back next frame. This is the immediate-mode pattern: no bindings, no observers, no two-way data flow. Just pass the value in, get the (possibly changed) value out.
progress shows a non-interactive progress bar. quit() programmatically closes the window when the user hits the close button.
A real app: the todo list
Here’s where Prove’s verb system shines. A todo list needs text input, dynamic lists, custom events, and keyboard shortcuts. Let’s build one:
module TodoApp
narrative: """
GUI todo list with add, toggle, and delete.
renders a todo app GUI interface
listens for key events
outputs check items and a button to add new
"""
Graphic types GraphicAppEvent outputs window button checkbox text_input
UI types Key
Sequence derives set remove creates length
type TodoItem is
text String
done Boolean
idx Integer
type TodoState is
items List<TodoItem>
type TodoApp is GraphicAppEvent
| AddItem(text String)
| RemoveItem(index Integer)First, the setup. We import Graphic widgets, the Key type for keyboard handling, and Sequence operations for list manipulation. Our state is a list of TodoItem records. The event type extends GraphicAppEvent with two app-specific variants: AddItem and RemoveItem.
outputs check(item TodoItem) TodoApp
from
checkbox(item.idx, item.text, item.done)
match button(f"{item.text}")
true => RemoveItem(item.idx)
false => Tick(Unit)The check function is an outputs verb — it produces GUI output and returns an event. Each todo item gets a checkbox and a delete button. Clicking the button emits RemoveItem.
renders interface(registered_attached_verbs List<Listens>)
event_type TodoApp
state_init TodoState([])
from
Draw(state) =>
window("Todo List", 400, 500)
text as String = match text_input("Todo", "")
Some(text) => text
_ => "New item"
match button("Add")
true =>
AddItem(text)
false =>
each(state.items, |item| check(item))
Draw(state)
AddItem(text) =>
items as List<TodoItem> = state.items
state.items = set(items, length(state.items), TodoItem(text, false,
length(items)))
Draw(state)
RemoveItem(index) =>
state.items = remove(state.items, index)
Draw(state)
Tick(state) => Draw(state)
Exit(state) => UnitThe render loop handles three things: drawing the UI, adding items, and removing items. The text_input widget returns Some(text) with the current input value. The each builtin iterates over items, calling check for each one — which renders the checkbox and delete button.
listens on_key() TodoApp
event_type KeyDown
state_type TodoState
from
Key:Escape => Exit(state)
Key:Enter => AddItem("new item")
_ => Tick(state)
main()
from
interface([on_key])&The listens verb translates raw keyboard events into app events. Escape quits, Enter adds an item. The main function wires everything together — the render loop receives the list of listeners.
This is the Prove pattern: verbs compose. The renderer owns the loop. Listeners translate events. Output functions encapsulate widget groups. Each piece declares what it does and the compiler enforces the boundaries.
What are small GUI apps good for?

We live in a world of Electron apps and cloud dashboards. Why build a 400×300 SDL window?
Internal tools. Every team has spreadsheets and scripts that should be small apps. A log viewer that filters by severity. A config editor that validates before saving. A test runner with a progress bar. These don’t need a web framework — they need a window, a few buttons, and an afternoon.
Data entry. When you need a human to classify, label, or review data, a small GUI app with checkboxes and text fields is faster than a web form. No auth, no deployment, no latency. Just compile and hand someone the binary.
Prototyping. Before you build the real UI, build a tiny one. A slider that controls a parameter, a button that triggers a pipeline, a label that shows the result. Prove compiles to native C — these tools start instantly and use minimal resources.
Learning. GUI programming teaches state management, event handling, and the rendering loop — core concepts that transfer everywhere. Prove’s explicit state and pattern-matched events make these concepts visible instead of hiding them behind framework magic.
Personal tools. A Pomodoro timer. A color picker. A quick calculator with domain-specific buttons. The kind of thing you build for yourself because the existing options have too many features or too few.
The immediate-mode advantage
Prove’s Graphic module uses immediate-mode rendering: you describe the UI every frame, and the library figures out what changed. There’s no widget tree to build, no state to synchronize, no lifecycle hooks.
This maps naturally to Prove’s functional style. Your render function is a pure transformation from state to UI. The renders verb receives an event, updates state, calls widgets, and returns the next event. The runtime handles the frame loop, vsync, and input polling.
For small apps, this is ideal. You don’t need the overhead of a retained-mode framework when your UI fits in 50 lines. And because Prove compiles to C with SDL2, your app is a single native binary — no runtime, no garbage collector, no JVM startup time.
Getting started
- Install SDL2:
# macOS
brew install sdl2
# Linux
apt install libsdl2-dev
- Create a new Prove project:
prove new my_app
- Import the Graphic module and start building:
Graphic types GraphicAppEvent outputs window button label- Build and run:
prove build && ./dist/my_app
The examples shown in this post are available in the examples/ directory of the Prove repository: gui_counter, gui_widgets_demo, and gui_todo.
Small apps deserve good tools. Prove gives you native compilation, explicit state management, and a widget set that fits in your head. Go build something small and useful.
Join #prove on Libera.Chat (just do a /knock and you will be let in ASAP)



Leave a Reply