Tutorial
Build a todo app #
This tutorial starts with an empty directory and ends with a todo app. The app runs as a native program and in the browser. Each step adds one feature and explains it.
Before you start, do the requirements step of Getting started. The tutorial takes approximately 30 minutes.
Step 1
Create the project #
Make a directory and let Zig make a project in it:
mkdir todo
cd todo
zig init
zig init makes build.zig, build.zig.zon,
src/main.zig, and src/root.zig. This app does not use a
library module, so delete src/root.zig:
rm src/root.zig
Add Knots as a dependency:
zig fetch --save git+https://github.com/knots-ui/knots.git
Step 2
Configure the build #
Replace all of build.zig with this code. It gets the Knots dependency,
imports two modules, and adds a run step.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const knots = b.dependency("knots", .{
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "todo",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "knots", .module = knots.module("knots") },
.{ .name = "ui", .module = knots.module("ui") },
},
}),
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
run_cmd.addPassthruArgs();
b.step("run", "Run the app").dependOn(&run_cmd.step);
}
knotscontainsApp, which owns the window.uicontains the components, styles, and the frame type.
Step 3
Open a window #
Replace all of src/main.zig with this code:
const std = @import("std");
const knots = @import("knots");
const ui = @import("ui");
pub fn main(init: std.process.Init) !void {
var app = try knots.App.init(init.io, init.gpa, .{
.window = .{ .width = 720, .height = 640, .title = "Todo" },
});
defer app.deinit();
try app.start(frame);
}
fn frame(_: *knots.View, context: *ui.Frame) !void {
const size = context.input().logical_extent;
try context.e(ui.component.Rect{
.key = .src(@src()),
.style = &.{
.width = .fixed(@floatFromInt(size.width)),
.height = .fixed(@floatFromInt(size.height)),
.background = .bg,
},
});
}
Run the app:
zig build run
An empty window opens. The code does these steps:
App.initopens the window and starts the GPU renderer.-
App.startruns the event loop. It callsframewhen the window needs a new frame. -
context.input().logical_extentis the window size in logical pixels. -
Rectis the basic box. Itsstylesets its size and its background color..bgis a theme color.
Step 4
Hold the application state #
The frame function has no state of its own. Put the app data in a struct. Make the
struct the file itself, and put knots.App in a field. The frame function
can then find the struct from view.app.
Remove the main and frame functions from step 3. Add this
code below the imports:
const Todo = struct {
id: usize,
title: []u8,
done: bool = false,
};
app: knots.App,
/// Owns every title for the life of the app.
arena: std.heap.ArenaAllocator,
todos: std.ArrayList(Todo) = .empty,
/// The text in the input field. TextInput grows it with the UI allocator.
draft: std.ArrayList(u8) = .empty,
next_id: usize = 0,
const Self = @This();
pub fn init(io: std.Io, allocator: std.mem.Allocator) !Self {
var self: Self = .{
.app = try knots.App.init(io, allocator, .{
.window = .{ .width = 720, .height = 640, .title = "Todo" },
.ui = .{ .theme = ui.Theme.dark },
}),
.arena = .init(allocator),
};
errdefer self.deinit();
try self.add("Read the Knots docs");
return self;
}
pub fn deinit(self: *Self) void {
self.draft.deinit(self.arena.child_allocator);
self.arena.deinit();
self.app.deinit();
}
fn add(self: *Self, title: []const u8) !void {
const trimmed = std.mem.trim(u8, title, " \t\r\n");
if (trimmed.len == 0) return;
const arena = self.arena.allocator();
try self.todos.append(arena, .{ .id = self.next_id, .title = try arena.dupe(u8, trimmed) });
self.next_id += 1;
}
pub fn main(process: std.process.Init) !void {
var self = try Self.init(process.io, process.gpa);
defer self.deinit();
try self.app.start(frame);
}
pub fn frame(view: *knots.View, context: *ui.Frame) !void {
const self: *Self = @alignCast(@fieldParentPtr("app", view.app));
_ = self;
const size = context.input().logical_extent;
try context.e(ui.component.Rect{ .key = .src(@src()), .style = &.{
.width = .fixed(@floatFromInt(size.width)),
.height = .fixed(@floatFromInt(size.height)),
.background = .bg,
} });
}
-
.ui = .{ .theme = ui.Theme.dark }selects the dark theme. The default theme isui.Theme.light. -
@fieldParentPtr("app", view.app)gets theSelfthat contains the app. For this reason,selfmust not move whilestartruns. -
Each todo has an
idthat does not change. Step 8 uses it for keys.
Step 5
Lay out the page #
Put a centered column in the window. The column fills the height, and its width stops at 600 pixels. Add these declarations below the imports:
const Rect = ui.component.Rect;
const Text = ui.component.Text;
const TextInput = ui.component.TextInput;
const Button = ui.component.Button;
const Checkbox = ui.component.Checkbox;
const ProgressBar = ui.component.ProgressBar;
Then replace the body of frame:
pub fn frame(view: *knots.View, context: *ui.Frame) !void {
const self: *Self = @alignCast(@fieldParentPtr("app", view.app));
const size = context.input().logical_extent;
// A max on the cross axis of a column is not applied, so calculate the width.
const column_width = @min(@as(f32, @floatFromInt(size.width)) - 48, 600);
try context.e(.{
Rect{ .key = .src(@src()), .style = &.{
.width = .fixed(@floatFromInt(size.width)),
.height = .fixed(@floatFromInt(size.height)),
.padding = .xy(24, 32),
.direction = .column,
.@"align" = .center,
.background = .bg,
} },
.{
Rect{ .key = .src(@src()), .style = &.{
.width = .fixed(column_width),
.height = .grow(),
.direction = .column,
.gap = 16,
} },
.{
Header{ .self = self },
Entry{ .self = self },
Rect{ .key = .src(@src()), .style = &card },
.{List{ .self = self }},
},
},
});
}
A component followed by a tuple is a parent. The tuple holds its children. The outer
Rect is a column that centers its children on the cross axis. The inner
Rect gets a fixed width: the window width less the padding, to a maximum
of 600 pixels. Knots does not apply max to a .grow() width
in a column, so the code calculates the width.
Header, Entry, List, and card do
not exist yet. The next steps add them. The
Layout engine page explains sizes, direction, and
alignment.
Step 6
Show a header #
A custom component is a struct with a render function. Give it the data
that it needs as fields. Add this code:
const Header = struct {
self: *Self,
pub fn render(header: *const Header, context: *ui.Frame) anyerror!void {
const todos = header.self.todos.items;
var done: usize = 0;
for (todos) |todo| done += @intFromBool(todo.done);
try context.e(.{
Rect{ .key = .src(@src()), .style = &.{
.width = .grow(),
.direction = .row,
.@"align" = .center,
.justify = .space_between,
} },
.{
Text{ .key = .src(@src()), .content = "Todos", .style = &.{ .font_size = .xl }, .selectable = false },
Text{
.key = .src(@src()),
.content = try std.fmt.allocPrint(context.arena(), "{d} of {d} done", .{ done, todos.len }),
.style = &.{ .font_size = .sm, .foreground = .dimmed },
.selectable = false,
},
},
});
}
};
-
context.arena()is an allocator that Knots clears after each frame. Use it for text that you make during the frame. -
.justify = .space_betweenputs the title at the left and the count at the right. .selectable = falsestops the user from selecting the text.
Step 7
Add new todos #
The entry row has a text field and a button. The user adds a todo with the button or with the Enter key. Add this code:
const input_key: ui.Key = .str("todo.input");
const Entry = struct {
self: *Self,
pub fn render(entry: *const Entry, context: *ui.Frame) anyerror!void {
const row: Rect = .{ .key = .src(@src()), .style = &.{
.width = .grow(),
.direction = .row,
.gap = 8,
.@"align" = .center,
} };
_ = try row.open(context);
defer row.close(context) catch {};
const enter = context.ui().focused(input_key.hash()) and
context.ui().input.keyPressed(.enter);
try context.e(TextInput{
.key = input_key,
.buf = &entry.self.draft,
.placeholder = "What needs doing?",
.style = &.{ .width = .grow() },
});
const clicked = (try context.interact(Button{
.key = .src(@src()),
.label = "Add",
.style = &.{ .padding = .xy(16, 0) },
})).clicked;
if (enter or clicked) {
try entry.self.add(entry.self.draft.items);
entry.self.draft.clearRetainingCapacity();
context.requestRedraw();
}
}
};
-
openandcloseare a second way to make a parent. Use them when the children need statements, as here. -
TextInputeditsdraftdirectly. It uses the allocator of the UI, which is the app allocator. -
context.interactemits a component and returns its response. AButtonresponse hasclickedandhovered. -
The input field has a fixed key,
input_key. The code uses the key to check if the field has focus. -
context.requestRedraw()asks for one more frame. Do this after you change data, so that the change shows immediately.
Step 8
List the todos #
Each row has a checkbox and a remove button. Rows come from a loop, so
.src(@src()) is not sufficient: each row would get the same key. Use
ui.Key.str(...).indexed(todo.id) to make one key for each todo.
const List = struct {
self: *Self,
pub fn render(list: *const List, context: *ui.Frame) anyerror!void {
var remove: ?usize = null;
for (list.self.todos.items, 0..) |*todo, index| {
const row: Rect = .{ .key = ui.Key.str("todo.row").indexed(todo.id), .style = &.{
.width = .grow(),
.height = .fixed(40),
.padding = .xy(10, 0),
.direction = .row,
.@"align" = .center,
.radius = .md,
.hover = &.{ .background = .muted },
} };
_ = try row.open(context);
const check = try context.interact(Checkbox{
.key = ui.Key.str("todo.check").indexed(todo.id),
.checked = &todo.done,
.label = todo.title,
.style = &.{ .width = .grow(), .foreground = if (todo.done) .dimmed else .text },
});
if (check.changed) context.requestRedraw();
const remove_button = try context.interact(Button{
.key = ui.Key.str("todo.remove").indexed(todo.id),
.label = "Remove",
.style = &ghost,
});
if (remove_button.clicked) remove = index;
try row.close(context);
}
if (remove) |index| {
_ = list.self.todos.orderedRemove(index);
context.requestRedraw();
}
}
};
-
Use the todo
idin the key, not the list index. When a todo is removed, the other rows keep their keys, so their hover and focus state stays correct. -
Checkboxwrites totodo.donethrough the pointer.changedis true in the frame where the value changed. - The code removes the todo after the loop. Do not change a list while you iterate over it.
-
.hoveris a style that Knots applies when the pointer is over the row.
Step 9
Add shared styles #
The code uses two styles that do not exist yet: card and
ghost. A style is a normal ui.Style value, so you can declare
it once and use it in many places. Add this code:
const card: ui.Style = .{
.width = .grow(),
.height = .grow(),
.direction = .column,
.padding = .all(6),
.gap = 2,
.overflow = .scroll_y,
.background = .elevated,
.radius = .lg,
.border_width = .all(1),
.border_color = .toned,
};
const ghost: ui.Style = .{
.height = .fixed(28),
.padding = .xy(8, 0),
.font_size = .xs,
.background = .transparent,
.foreground = .dimmed,
.hover = &.{ .background = .muted, .foreground = .text, .state_layer = 0 },
};
-
.overflow = .scroll_ymakes the card scroll when the list is taller than the card. -
Colors such as
.elevatedand.tonedare theme tokens. They change when the theme changes. -
ghostchanges the default button style. The fields that you set replace the fields of the default. The other fields stay the same.
Run the app. You can now add, complete, and remove todos.
zig build run
The Styles and themes page explains all style fields, states, and tokens.
Step 10
Show progress #
Add a progress bar and a button that removes completed todos. First, add this function
to Self:
fn clearDone(self: *Self) void {
var kept: usize = 0;
for (self.todos.items) |todo| {
if (todo.done) continue;
self.todos.items[kept] = todo;
kept += 1;
}
self.todos.shrinkRetainingCapacity(kept);
}
Then, in Header.render, add a ProgressBar after the title
row, and add the button after context.e:
const total: f32 = @floatFromInt(@max(todos.len, 1));
try context.e(.{
Rect{ ... }, // The title row from step 6.
.{ ... },
ProgressBar{
.key = .src(@src()),
.progress = @as(f32, @floatFromInt(done)) / total,
},
});
if (done > 0 and (try context.interact(Button{
.key = .src(@src()),
.label = "Clear completed",
.style = &ghost,
})).clicked) {
header.self.clearDone();
context.requestRedraw();
}
The button shows only when one or more todos are done. This is normal Zig control flow.
In immediate mode, an if statement is enough to show or hide a component.
Step 11
Run in the browser #
The same code can compile to WebAssembly. A browser program has no
main function that runs to the end. Instead, the page calls an exported
start function. Three changes are necessary.
Change the build #
In build.zig, add const Knots = @import("knots"); at the top.
Set .web_threads = false on the dependency, so that the page does not need
special HTTP headers. Then replace everything after the exe declaration:
const run_step = b.step("run", "Run the app");
if (target.result.cpu.arch.isWasm() and target.result.os.tag == .freestanding) {
exe.entry = .disabled;
Knots.installWeb(b, knots, exe.root_module, exe, .{
.index_html = b.path("web/index.html"),
});
} else {
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
run_cmd.addPassthruArgs();
run_step.dependOn(&run_cmd.step);
}
Add a browser entry point #
Make src/web.zig. It creates the app on the heap, because the app must
stay alive after the start function returns.
const knots = @import("knots");
const Todo = @import("main.zig");
fn start() callconv(.{ .wasm_mvp = .{} }) i32 {
const allocator = knots.web.allocator;
const self = allocator.create(Todo) catch |err| return knots.web.fail(err);
self.* = Todo.init(knots.web.io, allocator) catch |err| {
allocator.destroy(self);
return knots.web.fail(err);
};
self.app.start(Todo.frame) catch |err| {
self.deinit();
allocator.destroy(self);
return knots.web.fail(err);
};
return 0;
}
comptime {
@export(&start, .{ .name = "main" });
}
In src/main.zig, rename main to nativeMain.
Then add this code:
pub const std_options: std.Options = if (knots.platform.is_browser_wasm)
.{ .logFn = knots.web.logFn }
else
.{};
pub const main = if (knots.platform.is_browser_wasm) struct {
fn main() void {}
}.main else nativeMain;
comptime {
if (knots.platform.is_browser_wasm) _ = @import("web.zig");
}
Also add .canvas_selector = "#canvas" to the .window options
in init. In the browser, the window is this canvas element. Native builds
ignore the field.
Add the page #
Make web/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Todo</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; }
canvas { display: block; width: 100vw; height: 100vh; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module">
import { startKnots } from "./knots.js";
startKnots({ wasmUrl: "./app.wasm", canvas: "#canvas" }).catch(console.error);
</script>
</body>
</html>
Build and serve the files:
zig build -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall
python3 -m http.server 8000 --directory zig-out/web
Open http://localhost:8000 in a browser that supports WebGPU. The
zig-out/web directory contains all files that you must deploy:
index.html, knots.js, js-bridge.js, and
app.wasm.
The complete program #
The complete project is in the
examples/tutorial
directory of the website repository. The live todo app on the
home page is a larger version of this program. It also follows the
color scheme of the page.
Show the complete src/main.zig
const std = @import("std");
const knots = @import("knots");
const ui = @import("ui");
const Rect = ui.component.Rect;
const Text = ui.component.Text;
const TextInput = ui.component.TextInput;
const Button = ui.component.Button;
const Checkbox = ui.component.Checkbox;
const ProgressBar = ui.component.ProgressBar;
pub const std_options: std.Options = if (knots.platform.is_browser_wasm) .{ .logFn = knots.web.logFn } else .{};
const Todo = struct {
id: usize,
title: []u8,
done: bool = false,
};
app: knots.App,
arena: std.heap.ArenaAllocator,
todos: std.ArrayList(Todo) = .empty,
draft: std.ArrayList(u8) = .empty,
next_id: usize = 0,
const Self = @This();
const input_key: ui.Key = .str("todo.input");
const card: ui.Style = .{
.width = .grow(),
.height = .grow(),
.direction = .column,
.padding = .all(6),
.gap = 2,
.overflow = .scroll_y,
.background = .elevated,
.radius = .lg,
.border_width = .all(1),
.border_color = .toned,
};
const ghost: ui.Style = .{
.height = .fixed(28),
.padding = .xy(8, 0),
.font_size = .xs,
.background = .transparent,
.foreground = .dimmed,
.hover = &.{ .background = .muted, .foreground = .text, .state_layer = 0 },
};
pub fn init(io: std.Io, allocator: std.mem.Allocator) !Self {
var self: Self = .{
.app = try knots.App.init(io, allocator, .{
.window = .{ .width = 720, .height = 640, .title = "Todo", .canvas_selector = "#canvas" },
.ui = .{ .theme = ui.Theme.dark },
}),
.arena = .init(allocator),
};
errdefer self.deinit();
try self.add("Read the Knots docs");
return self;
}
pub fn deinit(self: *Self) void {
self.draft.deinit(self.arena.child_allocator);
self.arena.deinit();
self.app.deinit();
}
fn add(self: *Self, title: []const u8) !void {
const trimmed = std.mem.trim(u8, title, " \t\r\n");
if (trimmed.len == 0) return;
const arena = self.arena.allocator();
try self.todos.append(arena, .{ .id = self.next_id, .title = try arena.dupe(u8, trimmed) });
self.next_id += 1;
}
fn clearDone(self: *Self) void {
var kept: usize = 0;
for (self.todos.items) |todo| {
if (todo.done) continue;
self.todos.items[kept] = todo;
kept += 1;
}
self.todos.shrinkRetainingCapacity(kept);
}
pub fn frame(view: *knots.View, context: *ui.Frame) !void {
const self: *Self = @alignCast(@fieldParentPtr("app", view.app));
const size = context.input().logical_extent;
// A max on the cross axis of a column is not applied, so calculate the width.
const column_width = @min(@as(f32, @floatFromInt(size.width)) - 48, 600);
try context.e(.{
Rect{ .key = .src(@src()), .style = &.{
.width = .fixed(@floatFromInt(size.width)),
.height = .fixed(@floatFromInt(size.height)),
.padding = .xy(24, 32),
.direction = .column,
.@"align" = .center,
.background = .bg,
} },
.{
Rect{ .key = .src(@src()), .style = &.{
.width = .fixed(column_width),
.height = .grow(),
.direction = .column,
.gap = 16,
} },
.{
Header{ .self = self },
Entry{ .self = self },
Rect{ .key = .src(@src()), .style = &card },
.{List{ .self = self }},
},
},
});
}
const Header = struct {
self: *Self,
pub fn render(header: *const Header, context: *ui.Frame) anyerror!void {
const todos = header.self.todos.items;
var done: usize = 0;
for (todos) |todo| done += @intFromBool(todo.done);
const total: f32 = @floatFromInt(@max(todos.len, 1));
try context.e(.{
Rect{ .key = .src(@src()), .style = &.{ .width = .grow(), .direction = .row, .@"align" = .center, .justify = .space_between } },
.{
Text{ .key = .src(@src()), .content = "Todos", .style = &.{ .font_size = .xl }, .selectable = false },
Text{
.key = .src(@src()),
.content = try std.fmt.allocPrint(context.arena(), "{d} of {d} done", .{ done, todos.len }),
.style = &.{ .font_size = .sm, .foreground = .dimmed },
.selectable = false,
},
},
ProgressBar{ .key = .src(@src()), .progress = @as(f32, @floatFromInt(done)) / total },
});
if (done > 0 and (try context.interact(Button{ .key = .src(@src()), .label = "Clear completed", .style = &ghost })).clicked) {
header.self.clearDone();
context.requestRedraw();
}
}
};
const Entry = struct {
self: *Self,
pub fn render(entry: *const Entry, context: *ui.Frame) anyerror!void {
const row: Rect = .{ .key = .src(@src()), .style = &.{ .width = .grow(), .direction = .row, .gap = 8, .@"align" = .center } };
_ = try row.open(context);
defer row.close(context) catch {};
const enter = context.ui().focused(input_key.hash()) and context.ui().input.keyPressed(.enter);
try context.e(TextInput{ .key = input_key, .buf = &entry.self.draft, .placeholder = "What needs doing?", .style = &.{ .width = .grow() } });
const clicked = (try context.interact(Button{ .key = .src(@src()), .label = "Add", .style = &.{ .padding = .xy(16, 0) } })).clicked;
if (enter or clicked) {
try entry.self.add(entry.self.draft.items);
entry.self.draft.clearRetainingCapacity();
context.requestRedraw();
}
}
};
const List = struct {
self: *Self,
pub fn render(list: *const List, context: *ui.Frame) anyerror!void {
var remove: ?usize = null;
for (list.self.todos.items, 0..) |*todo, index| {
const row: Rect = .{ .key = ui.Key.str("todo.row").indexed(todo.id), .style = &.{
.width = .grow(),
.height = .fixed(40),
.padding = .xy(10, 0),
.direction = .row,
.@"align" = .center,
.radius = .md,
.hover = &.{ .background = .muted },
} };
_ = try row.open(context);
const check = try context.interact(Checkbox{
.key = ui.Key.str("todo.check").indexed(todo.id),
.checked = &todo.done,
.label = todo.title,
.style = &.{ .width = .grow(), .foreground = if (todo.done) .dimmed else .text },
});
if (check.changed) context.requestRedraw();
const remove_button = try context.interact(Button{
.key = ui.Key.str("todo.remove").indexed(todo.id),
.label = "Remove",
.style = &ghost,
});
if (remove_button.clicked) remove = index;
try row.close(context);
}
if (remove) |index| {
_ = list.self.todos.orderedRemove(index);
context.requestRedraw();
}
}
};
pub const main = if (knots.platform.is_browser_wasm) struct {
fn main() void {}
}.main else nativeMain;
fn nativeMain(process: std.process.Init) !void {
var self = try Self.init(process.io, process.gpa);
defer self.deinit();
try self.app.start(frame);
}
comptime {
if (knots.platform.is_browser_wasm) _ = @import("web.zig");
}
Next steps #
- Change UI code without a restart. Read Hot reloading.
-
Keep the todos after the app closes. Use normal Zig file I/O with
init.io. - Make your own theme. Read Themes.
- Ship the app. Read Compile & distribute.