Welcome to Nifty
Nifty is a small, friendly programming language for building real desktop apps, with real buttons, text fields and windows from your operating system.
Here is a complete Nifty app. It’s a window with a number and two buttons:
state count = 0
window("Counter", width: 320, height: 200) {
column(spacing: 12, align: .center, justify: .center) {
label("Clicked {count} times", style: .title)
row(spacing: 8) {
button("−") { count -= 1 }
button("+", style: .primary) { count += 1 }
}
}
}
That’s the whole program. No setup code, no event listeners, no “refresh the screen”
step. When you click +, count changes, and the label that shows count updates
by itself.
Why Nifty?
- It’s small. You can learn the whole language in an afternoon. This book covers all of it.
- It looks native. Nifty uses your operating system’s own widgets, so apps look and feel like every other app on your computer, including dark mode and system fonts.
- The UI updates itself. You say what the screen shows. Nifty works out when each part needs to change, and updates only those parts.
- Change it while it runs.
nf devreloads your app every time you save, and keeps what it remembered, so you see each change in a moment. - Mistakes are caught early. Nifty checks your program before it runs and explains
problems in plain words, like
`label` shows text; turn this `int` into text with "{…}".
Where Nifty is today: Nifty is young (version 0). It runs and builds apps on macOS and Windows; Linux comes next. See What Nifty supports.
Where to go from here
nf tool running in a few minutes.
Build a counter →A step-by-step first app. No experience needed.
Cheat sheet →The whole language on one page.
Browse the code →Every part of Nifty, with links to the source.
How this book is organized
| Part | Read it when… |
|---|---|
| Getting started | you want Nifty installed and an app on screen. |
| Tutorials | you’d like to learn by building something, one step at a time. |
| The guide | you want each idea explained properly, with examples. |
| Reference | you know what you’re looking for and need the exact details. |
| Under the hood | you’re curious how Nifty works, or want to help build it. |
Code in this book that looks like this is something you type or something Nifty
prints. Every page has an edit button (top right) if you spot something to fix.
Install Nifty
Nifty comes as one command-line tool called nf. You use it to create, check and
run apps. This page gets it onto your computer. You don’t need Rust or anything else
first.
macOS
Install it with Homebrew:
brew install istorry/tap/nifty-cli
Windows
Install it with winget, which comes with Windows 10 and 11:
winget install nifty-cli
Open a new terminal window afterwards so it picks up nf.
Check it works
nf help
You should see:
usage: nf <command> [args]
commands:
create <name> create a new app in a folder called <name>
run [project] run an app (default: the current folder)
dev [project] run an app and reload it each time you save, keeping its state
check [project] report errors in a project (default: the current folder)
build [project] [--macos | --windows | --linux]
build a native app (default: for this system)
lsp run the language server on stdin and stdout, for editors
version print the version of nf
A project is a folder with a main.nf file, or a single .nf file.
That’s it. Next, make your first app.
To build apps to share with nf build, you’ll also need your system’s linker; see
Share your app.
Updating
| System | Command |
|---|---|
| macOS | brew upgrade nifty-cli |
| Windows | winget upgrade nifty-cli |
Building from source
If you’d rather build nf yourself, or want to work on Nifty, see
Building from source.
If something goes wrong
| What you see | What to do |
|---|---|
command not found: nf (macOS) | Run brew doctor and follow what it says about your PATH. |
'nf' is not recognized (Windows) | Open a new terminal window. winget adds nf to your PATH, but only new windows see it. |
No package found matching input criteria (Windows) | Run winget source update, then try again. |
libnifty_rt.a … is missing or nifty_rt.lib … is missing | nf build needs the runtime library that’s installed beside nf. Reinstall Nifty with the command above. |
can't run the linker or xcrun: error (macOS) | Install Xcode’s command line tools: xcode-select --install. |
building apps needs Microsoft's C++ Build Tools (Windows) | Install them with the command the error shows, then build again. |
Your first app
Let’s create an app, run it, and change it. You’ll need nf installed
(Install Nifty).
Create it
In your terminal, go to a folder where you keep projects and run:
nf create hello
nf makes a new folder for you:
Created hello/main.nf
Run it with:
nf run hello
A Nifty project is just a folder with a file called main.nf in it. That file is
your whole app.
App names must start with a letter and use only letters, digits,
-and_.
Run it
nf run hello
A window opens with a counter. Click the buttons and watch the number change. Close the window (or press ⌘Q on macOS, Alt+F4 on Windows) to stop the app.
Look inside
Open hello/main.nf in any text editor. You’ll see:
state count = 0
window("hello", width: 320, height: 200) {
column(spacing: 12, align: .center, justify: .center) {
label("Clicked {count} times", style: .title)
row(spacing: 8) {
button("−") { count -= 1 }
button("+", style: .primary) { count += 1 }
}
}
}
Read it top to bottom:
state count = 0creates a value calledcountthat starts at0. State is the data your app remembers. When it changes, the screen updates.window("hello", …)makes a window with the title “hello”, 320 points wide and 200 tall.column(…)stacks things top to bottom.row(…)puts them side by side.label("Clicked {count} times", …)shows text. The{count}part is replaced by the current value ofcount.button("+") { count += 1 }is a button. The code in{ … }runs when you click it.
Change it
Let’s add a Reset button. Inside the row, after the + button, add one line:
row(spacing: 8) {
button("−") { count -= 1 }
button("+", style: .primary) { count += 1 }
button("Reset") { count = 0 }
}
Save the file and run it again:
nf run hello
You now have three buttons.
Change it while it runs
Quitting and running again gets old fast. Use nf dev instead:
nf dev hello
The app opens as before, but now nf dev watches your file. Click + a few times,
then change "Clicked {count} times" to "Count: {count}" and save. The window
updates straight away, and the count stays where it was.
If you save a mistake, nf dev prints the error and keeps showing the last version that
worked, so you can fix it and save again. Press Ctrl+C in the terminal (or quit the
app) when you’re done.
Tip: Keep
nf devrunning in one window and your editor in another. It’s the fastest way to build a Nifty app.
Check for mistakes
Try breaking something on purpose. Change the label to label(count) (without the
quotes) and run:
nf check hello
Nifty tells you what’s wrong and where:
Error: `label` shows text; turn this `int` into text with "{…}"
╭─[ hello/main.nf:5:11 ]
│
5 │ label(count)
───╯
1 error
nf check looks for problems without opening any windows. nf run does the same check
first, so a broken app never starts half-working. Put the quotes back and you’re good.
Tip: Inside the project folder you can leave the name off:
cd hellothen justnf runornf check.
What’s next
- Set up syntax highlighting and live errors in your editor.
- Learn properly with the counter tutorial.
- Turn your app into a real
.appto share: Share your app.
Set up your editor
Nifty files end in .nf. Any text editor can open them, but with editor support you
get colors, errors as you type, and jumping to definitions.
All editor features come from nf lsp, a language server built into nf.
Editors start it for you; you never run it by hand. So first make sure nf is
installed and on your PATH (Install Nifty).
| Feature | VS Code | Zed |
|---|---|---|
| Syntax colors | ✓ | ✓ |
| Errors as you type | ✓ | ✓ |
| Autocomplete | ✓ | ✓ |
| Signature help and hover docs | ✓ | ✓ |
| Outline of your file | ✓ | ✓ |
| Go to definition | ✓ | ✓ |
| Find all references | ✓ | ✓ |
| Highlight the name under the cursor | ✓ | ✓ |
| Auto-indent, brackets, text objects | ✓ |
VS Code
The extension lives in
editors/vscode. It
isn’t on the Marketplace yet, so you run it from the source:
-
Build it once:
cd editors/vscode npm install npm run compile -
Open the
editors/vscodefolder in VS Code and press F5. A second VS Code window opens with Nifty support turned on. Open any.nffile there.
If nf isn’t on your PATH, set nifty.server.path in VS Code’s settings to the
full path of the nf program.
After you rebuild nf, run the command Nifty: Restart Language Server from the
command palette (⇧⌘P on macOS, Ctrl+Shift+P on Windows).
Zed
The extension lives in
editors/zed.
- In Zed, open the command palette (⇧⌘P on macOS, Ctrl+Shift+P on Windows) and run zed: install dev extension.
- Pick the
editors/zedfolder.
If nf isn’t on your PATH, tell Zed where it is in settings.json:
{
"lsp": {
"nifty": {
"binary": { "path": "/path/to/nf" }
}
}
}
After you rebuild nf, run editor: restart language server.
Other editors
Any editor that speaks the
Language Server Protocol can
use Nifty. Configure it to start the command nf lsp for files ending in .nf.
For syntax colors, there’s a Tree-sitter grammar (used by Zed, Neovim, Helix and others) and a TextMate grammar (used by VS Code, Sublime Text and others).
Share your app
nf run is great while you work, but other people don’t have nf. To give them your
app, build it:
nf build todos
On macOS you get a .app:
built todos/build/todos.app
That’s a normal macOS app. Double-click it in Finder, drag it into Applications, or send it to a friend.
On Windows you get a folder:
built todos\build\todos
Inside is todos.exe. Double-click it to run it, or zip the todos folder to send it.
What nf build does
- Checks your app. If there are errors, it shows them and builds nothing, just
like
nf run. - Compiles it to machine code, and links that with Nifty’s runtime library into
one program. Your
.nffiles aren’t inside the app. - Makes the app in a
buildfolder next to yourmain.nf, named after the project folder (todosbecomestodos.appon macOS, ortodos\todos.exeon Windows). If you build a single file such asdemo.nf, the app is calleddemo. - Replaces any earlier build, so
build/always holds the latest version.
Linking uses your system’s own linker, which nf run doesn’t need:
-
macOS: Xcode’s command line tools. Install them with
xcode-select --install. -
Windows: Microsoft’s C++ Build Tools. Install them with:
winget install Microsoft.VisualStudio.2022.BuildTools --override "--passive --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
If something goes wrong while the app runs, such as dividing by zero, it stops and writes the error and where in your code it happened to the terminal it was started from.
Tip: add
build/to your project’s.gitignore. It’s made from your code, so there’s no need to keep it in version control.
Building for other systems
By default nf build builds for the system you’re on. A flag names the system instead:
nf build --macos
nf build --windows
nf build --linux
nf builds apps for the system it runs on, so build macOS apps on a Mac and Windows apps
on Windows. Naming another system explains that. Linux has no backend yet; see
What Nifty supports.
Opening the app on another Mac
Apps from nf build aren’t signed with an Apple developer certificate, so the first
time someone else opens one, macOS may block it because it can’t check who made it.
They can allow it once: after the warning, open System Settings → Privacy & Security,
scroll down and click Open Anyway. (On older macOS versions, right-click the app
and choose Open instead.)
Opening the app on another Windows PC
Apps from nf build aren’t signed with a code-signing certificate, so the first time
someone opens one they’ve downloaded, Windows may show Windows protected your PC.
They can click More info, then Run anyway.
Build a counter
In this tutorial you’ll build a counter app from an empty file, one small step at a time. Each step runs, so you can see what every line does.
You’ll learn: windows, labels, buttons, state, and how the screen updates itself.
Step 0: An empty project
Make a folder called counter with an empty file main.nf inside:
mkdir counter
touch counter/main.nf
Open counter/main.nf in your editor. Then start nf dev in a terminal and leave it
running:
nf dev counter
The file is empty, so for now nf dev reports an error and waits. Each time you save,
it checks your code and updates the window, so you’ll see every step below appear as you
save it. (Prefer to run each step yourself? Use nf run counter after each one.)
Step 1: A window
Every Nifty app needs at least one window. Type this:
window("Counter") {
}
Save. You get an empty window titled Counter.
window(...)is an element: a built-in piece of UI."Counter"is the window’s title.- The
{ }after it holds what goes inside the window. It’s empty for now.
Step 2: Some text
Put a label inside the window:
window("Counter") {
label("Hello!")
}
Save. The window now says Hello!
Step 3: A size
The window is bigger than it needs to be. Give it a size in points, a screen unit that looks the same size on any display:
window("Counter", width: 320, height: 200) {
label("Hello!")
}
width: 320 is a named argument: you say which setting you mean, then its value.
Named arguments always come after the unnamed ones like "Counter".
Step 4: Remember a number
Now the important part. Add a line at the very top:
state count = 0
window("Counter", width: 320, height: 200) {
label("Clicked {count} times")
}
state count = 0makes a piece of state calledcountthat starts at0. State is the data your app remembers while it runs.- Inside the text,
{count}is replaced by the value ofcount. This is called interpolation. You can put any expression in the braces, like{count * 2}.
Save: Clicked 0 times.
Step 5: A button
Add a button under the label. Because there are now two things in the window, wrap
them in a column, which stacks things top to bottom:
state count = 0
window("Counter", width: 320, height: 200) {
column() {
label("Clicked {count} times")
button("+") { count += 1 }
}
}
Save and click + a few times. The number goes up!
Look at what you didn’t write. There’s no “when the button is clicked, find the label and change its text” code. You only said:
- the label shows
count, and - the button adds 1 to
count.
Nifty connects the two. When count changes, every part of the screen that uses
count updates. (Curious how? See How the UI stays up to date.)
Watch out: elements always need parentheses, even with nothing inside them. Write
column() { … }, notcolumn { … }.
Step 6: A minus button
Put the two buttons side by side in a row:
state count = 0
window("Counter", width: 320, height: 200) {
column() {
label("Clicked {count} times")
row() {
button("−") { count -= 1 }
button("+") { count += 1 }
}
}
}
count -= 1 means “take 1 away from count”. There are also *= and /=.
Step 7: Make it look nice
Finally, add some spacing, center everything, use a big title style for the number, and make + the main button:
state count = 0
window("Counter", width: 320, height: 200) {
column(spacing: 12, align: .center, justify: .center) {
label("Clicked {count} times", style: .title)
row(spacing: 8) {
button("−") { count -= 1 }
button("+", style: .primary) { count += 1 }
}
}
}
What’s new:
| Setting | What it does |
|---|---|
spacing: 12 | 12 points of space between children. |
align: .center | Centers children across the column (left to right). |
justify: .center | Centers children along the column (top to bottom). |
style: .title | Big, bold title text. |
style: .primary | Makes the button the main action: it’s drawn in the system’s accent color, and pressing Return clicks it. |
The values that start with a dot, like .center, are options. Each setting has its
own small list of options; the UI elements reference lists
them all.
You did it
That’s the same counter nf create gives you. Some things to try on your own:
-
Add a Reset button that sets
count = 0. -
Show a second label with
"Doubled: {count * 2}". -
Show a message only when the count is high:
if count >= 10 { label("That's a lot of clicks!", style: .caption, color: .warning) } -
Turn it into a real Mac app with
nf build counter. See Share your app.
Ready for more? Build a todo list next.
Build a todo list
This tutorial builds a todo list: type a task, add it, tick it off, and see how many are left. It picks up where Build a counter left off.
You’ll learn: structs, lists, text fields, checkboxes, for loops, functions and
your own views.
Create a new project to work in:
mkdir todos
touch todos/main.nf
Step 1: Describe a todo
A todo has two parts: its title, and whether it’s done. In Nifty you describe a shape of data like that with a struct:
struct Todo {
title: string
done: bool
}
This says: “a Todo has a title, which is text (string), and done, which is
true or false (bool).”
A struct is only a description. To make an actual todo, you call it like a function, naming each field:
Todo(title: "Buy milk", done: false)
Step 2: A list of todos
The app needs to remember a list of todos. Add this under the struct:
state todos: [Todo] = []
[Todo]means “a list ofTodos”.= []means it starts empty.
Why write the type here, when state count = 0 didn’t need one? Because Nifty can tell
0 is a number, but it can’t tell what an empty list [] will hold. So you say.
Step 3: Show the list
Add a window that shows every todo, using for:
struct Todo {
title: string
done: bool
}
state todos: [Todo] = [
Todo(title: "Buy milk", done: false),
Todo(title: "Write docs", done: true),
]
window("Todos", width: 360, height: 480) {
column(spacing: 12, padding: 20) {
for todo in todos {
label(todo.title)
}
}
}
We started the list with two todos so there’s something to see. for todo in todos
repeats its block once for every item; each time, todo is the current item.
todo.title reads the item’s title field.
Run it and you’ll see both titles.
Step 4: Checkboxes
Replace the label with a checkbox that’s ticked when the todo is done:
for todo in todos {
checkbox(checked: $todo.done, title: todo.title)
}
Notice the $ in $todo.done. That’s a two-way binding:
- The checkbox shows whether
todo.doneis true, and - when you click the checkbox, it changes
todo.done.
Without $, the checkbox could only show a value, never change it. Run the app and tick
some boxes. (More in Two-way bindings.)
Step 5: Typing new todos
To add todos we need somewhere to type. Add a second piece of state for the text being typed, and a text field bound to it. Start the list empty again, too:
state todos: [Todo] = []
state draft = ""
And at the top of the column:
row(spacing: 8) {
text_field($draft, placeholder: "What needs doing?")
button("Add", style: .primary) { add_todo() }
}
divider()
text_field($draft, …)showsdraftand updates it as you type.- The placeholder is the grey hint shown while the field is empty.
divider()draws a line.- The button calls
add_todo(), a function we’ll write next.
Step 6: A function to add todos
Put this function anywhere at the top level of the file (outside the window):
fn add_todo() {
if draft != "" {
todos.push(Todo(title: draft, done: false))
draft = ""
}
}
Read it out loud: “To add a todo: if the draft isn’t empty, push a new todo with that title onto the list, then clear the draft.”
fndeclares a function.!=means “is not equal to”.todos.push(…)adds an item to the end of the list.draft = ""empties the text field, because the field is bound todraft.
Run it. Type something, click Add (or press Return, since it’s the primary button), and your todo appears.
Step 7: Count what’s left
Let’s show how many todos aren’t done. Add a function that counts them and returns the answer:
fn remaining() -> int {
let count = 0
for todo in todos {
if !todo.done {
count += 1
}
}
return count
}
-> intsays this function gives back a whole number.let count = 0makes a local variable that only exists while the function runs.!todo.donemeans “not done”.return counthands the answer back.
Step 8: Your own view
The header will show a title on the left and the count on the right. You could write that inline, but giving it a name keeps the window tidy. A view is a reusable piece of UI you define yourself:
view Header(left: int) {
row(align: .center) {
label("Todos", style: .title)
spacer()
label("{left} left", style: .caption)
}
}
spacer() takes up all the free space, pushing the count to the right edge. Use the
view like any built-in element:
Header(left: remaining())
The header updates automatically whenever a todo is added or ticked, because
remaining() reads todos, and Nifty notices that.
The finished app
Here’s everything together:
struct Todo {
title: string
done: bool
}
state todos: [Todo] = []
state draft = ""
fn add_todo() {
if draft != "" {
todos.push(Todo(title: draft, done: false))
draft = ""
}
}
fn remaining() -> int {
let count = 0
for todo in todos {
if !todo.done {
count += 1
}
}
return count
}
view Header(left: int) {
row(align: .center) {
label("Todos", style: .title)
spacer()
label("{left} left", style: .caption)
}
}
window("Todos", width: 360, height: 480) {
column(spacing: 12, padding: 20) {
Header(left: remaining())
row(spacing: 8) {
text_field($draft, placeholder: "What needs doing?")
button("Add", style: .primary) { add_todo() }
}
divider()
for todo in todos {
checkbox(checked: $todo.done, title: todo.title)
}
}
}
Try this next
- Show a message when the list is empty:
if todos.is_empty() { label("All clear!") } - Add a Clear all button that sets
todos = []. - Show the total:
label("{todos.len()} total", style: .caption).
Now you know almost all of Nifty! The guide explains each idea in more depth, and the cheat sheet fits it all on one page.
Values and types
Every value in Nifty has a type that says what kind of thing it is. Nifty checks types before your app runs, so mixing up a number and some text is caught early instead of crashing later.
The basic types
| Type | What it holds | Examples |
|---|---|---|
int | whole numbers | 0, 42, -7, 1_000_000 |
float | numbers with a decimal point | 3.14, 0.5, -2.0 |
bool | yes or no | true, false |
string | text | "hello", "" |
[T] | a list of Ts | [1, 2, 3], ["a", "b"] |
| a struct name | your own record type | Todo(title: "Milk", done: false) |
A few details:
- Underscores in numbers are just for reading:
1_000is1000. - A
floatneeds digits on both sides of the dot: write0.5, not.5, and2.0, not2.. - There is no
nullor “nothing” value. If something is astring, it’s always a string.
Nifty works out types for you
You rarely write types. Nifty figures them out from the starting value:
state count = 0 // an int
state name = "Ada" // a string
state ready = false // a bool
You only write a type where Nifty can’t guess, like an empty list, function parameters, and what a function returns:
state todos: [Todo] = [] // empty list: say what goes in it
fn area(w: float, h: float) -> float { // parameters and return type
return w * h
}
Numbers don’t mix
int and float are separate. Nifty never converts one into the other behind your
back, so this is an error:
Error: can't use `+` with `int` and `float`
Write both sides as the same type, like 1.0 + 2.5 or 1 + 2.
Dividing two ints gives an int and drops the remainder: 7 / 2 is 3. Use % to
get the remainder: 7 % 2 is 1. Dividing by zero stops the app with an error that
points at the line.
Text and interpolation
Strings go in double quotes. To put a value inside text, wrap any expression in
{ }:
label("Hello, {name}!")
label("{done} of {total} done")
label("Twice as many: {count * 2}")
This is called interpolation, and it’s how you turn numbers into text. You can
join two strings with +, but joining a string and a number with + is an error;
Nifty will suggest interpolation instead.
Special characters are written with a backslash:
| Write | To get |
|---|---|
\n | a new line |
\t | a tab |
\" | a double quote |
\\ | a backslash |
\{ | a literal { (not interpolation) |
Strings have two methods: name.len() (how many characters) and name.is_empty().
Comments
Anything after // on a line is a comment, and Nifty ignores it:
state count = 0 // how many times the button was clicked
Operators
From the loosest to the tightest binding (the ones lower in the table happen first):
| Operator | Meaning |
|---|---|
a || b | a or b |
a && b | a and b |
== != < <= > >= | equal, not equal, less than, and so on |
+ - | add, subtract (+ also joins two strings) |
* / % | multiply, divide, remainder |
!a, -a | not, negative |
f(x), a.b, a[i] | call, field or method, list item |
So 1 + 2 * 3 is 7, and !done && count > 0 means (!done) && (count > 0). Use
parentheses whenever it makes things clearer.
Comparisons don’t chain: write 0 < x && x < 10, not 0 < x < 10.
Lines, not semicolons
Each statement goes on its own line. There are no semicolons. If a line gets long, you can break it:
- inside
( )or[ ], or - right after an operator like
+or&&.
let total = first_part +
second_part
button(
"Save",
style: .primary,
) { save() }
State
State is what your app remembers. The number in a counter, the todos in a list, the text someone has typed: all state.
state count = 0
state draft = ""
state todos: [Todo] = []
state lines go at the top level of your file, outside any window, function or view.
Every part of the app can read and change them.
State drives the screen
The special thing about state is that the UI follows it. When state changes, every part of the screen that reads it updates:
state count = 0
window("Demo") {
column() {
label("Count: {count}") // updates when count changes
button("+") { count += 1 }
}
}
You never tell a label to redraw. You change the state, and Nifty does the rest. See How the UI stays up to date for the details.
Changing state
You can change state in two places:
-
In a button’s action (or a function it calls):
button("Reset") { count = 0 } -
Through a two-way binding, when someone types in a text field or clicks a checkbox:
text_field($draft)
The ways to change a value:
count = 10 // set it
count += 1 // add to it
count -= 1 // take away
count *= 2 // multiply
count /= 2 // divide
todos.push(item) // add to the end of a list
todos.remove(0) // remove the first item of a list
todo.done = true // change one field
todos[0] = item // replace one list item
Showing state never changes it
Code that builds the screen can read state, but it can’t change it. This is an error:
fn bump() { count += 1 }
window("Oops") {
label("{bump()}") // ✗ showing the UI can't change state
}
This rule means drawing the screen is always safe and predictable: looking at your app never changes what it remembers.
Starting values
A state’s starting value can’t use other state, because there’s no order in which state gets created:
state a = 1
state b = a // ✗ a state's starting value can't use other state
If you need b to always follow a, don’t store it. Compute it with a
function instead.
Local variables
Inside functions and button actions, let makes a temporary variable that disappears
when the block ends:
fn remaining() -> int {
let count = 0
for todo in todos {
if !todo.done {
count += 1
}
}
return count
}
Use state for things the app should remember, and let for working things out.
Functions
A function gives a name to some code so you can use it from several places, or just to keep your window tidy.
fn add_todo() {
if draft != "" {
todos.push(Todo(title: draft, done: false))
draft = ""
}
}
Call it by name with parentheses:
button("Add") { add_todo() }
Parameters
Inputs go in the parentheses, each with a name and a type:
fn greet(name: string, times: int) {
...
}
Returning a value
If a function gives back an answer, write its type after ->, and use return:
fn area(width: float, height: float) -> float {
return width * height
}
fn remaining() -> int {
let count = 0
for todo in todos {
if !todo.done {
count += 1
}
}
return count
}
Nifty checks every path through the function returns something. If one path can reach
the end without a return, you’ll get:
Error: `remaining` can reach its end without returning a `int`
A function without -> returns nothing. You can still use a bare return to leave it
early.
Functions in the UI
Functions that return a value can be used anywhere the UI needs one:
label("{remaining()} left")
Header(left: remaining())
Nifty looks inside the function, sees it reads todos, and updates that label whenever
todos changes. You don’t have to tell it.
Functions used in the UI may only read state, not change it. Functions that change state belong in button actions.
Control flow
These work inside functions and button actions:
if count > 10 {
...
} else if count > 5 {
...
} else {
...
}
for todo in todos {
...
}
for index, todo in todos { // index is 0, 1, 2, …
...
}
for i in 0..5 { // i is 0, 1, 2, 3, 4
...
}
The condition of an if must be a bool. There’s no “truthy”: write
if name != "" or if !name.is_empty(), not if name.
Order doesn’t matter
Top-level things (state, fn, view, struct, window) can go in any order. A
function can use a struct declared below it, and a window can call a function declared
at the end of the file.
Structs and lists
Structs: your own types
A struct groups a few values into one thing. Declare it with its fields and their types:
struct Todo {
title: string
done: bool
}
Fields can go one per line (as above) or on one line with commas:
struct Point { x: float, y: float }
Make one by calling the struct’s name and giving every field by name:
let todo = Todo(title: "Buy milk", done: false)
Read and change fields with a dot:
label(todo.title)
todo.done = true
Lists
A list holds any number of values of the same type, in order.
state numbers = [1, 2, 3] // a [int]
state names: [string] = [] // an empty list needs its type
state todos: [Todo] = []
What you can do with a list:
| Write | Does |
|---|---|
items[0] | the first item (positions start at 0) |
items[i] = x | replace an item |
items.len() | how many items there are |
items.is_empty() | true if there are none |
items.push(x) | add x to the end |
items.remove(i) | remove the item at position i |
for item in items { … } | do something with each item |
for index, item in items { … } | the same, with each item’s position |
Asking for an item that isn’t there stops the app with a clear message, like
there is no item 3; the list has 2.
Values are copied
When you assign a struct or list, you get a copy. Changing the copy leaves the original alone:
let a = Todo(title: "x", done: false)
let b = a
b.done = true // a.done is still false
There’s one exception, and it’s the one you want: a for loop over state gives you
the real items, so you can change them in place:
fn finish_all() {
for todo in todos {
todo.done = true // changes the todos in state
}
}
Lists in the UI
A for loop inside a window or view shows its elements once per item:
for todo in todos {
checkbox(checked: $todo.done, title: todo.title)
}
When todos changes, the loop is rebuilt to match.
Positions and counting
Name a second variable before the item to get its position, counting from 0. A loop that finds an item can return its position, and a row can remove itself:
fn position_of(name: string) -> int {
for index, item in names {
if item == name {
return index
}
}
return -1
}
To repeat something a number of times, loop over a range. for i in 0..n counts i
from 0 up to, but not including, n, so 0..items.len() is every position in items.
The position and the count are set by the loop, so they can’t be assigned.
This app numbers its rows, removes the one you click, and draws a meter with a range:
state names = ["Ada", "Grace", "Linus"]
state draft = ""
state rating = 3
fn position_of(name: string) -> int {
for index, item in names {
if item == name {
return index
}
}
return -1
}
fn add_name() {
if draft != "" && position_of(draft) < 0 {
names.push(draft)
draft = ""
}
}
fn meter(filled: int) -> string {
let bar = ""
for step in 0..5 {
if step < filled {
bar = bar + "●"
} else {
bar = bar + "○"
}
}
return bar
}
window("Lineup", width: 320, height: 420) {
column(spacing: 12, padding: 20) {
for index, name in names {
row(spacing: 8) {
label("{index + 1}. {name}")
spacer()
button("Remove {name}") { names.remove(index) }
}
}
row(spacing: 8) {
text_field($draft, placeholder: "Name")
button("Add") { add_name() }
}
if position_of(draft) >= 0 {
label("{draft} is already number {position_of(draft) + 1}", style: .caption)
}
divider()
row(spacing: 4) {
button("Fewer") { rating -= 1 }
for star in 0..rating {
label("★{star + 1}")
}
button("More") { rating += 1 }
}
label(meter(rating))
}
}
Building the UI
In Nifty you describe what the screen should look like. You don’t create widgets, keep track of them, or update them. Nifty does that.
Windows
Every app has at least one window. Windows go at the top level of the file:
window("Notes", width: 400, height: 300) {
label("Hello")
}
The title is required. width and height are optional; the default size is 480 × 320.
An app can have more than one window. Each is its own window(…) { … } block.
Give a window a name, between window and its title, and code can open and close it
by that name. With open: false it waits for code to open it, rather than opening when
the app starts:
window main("Downloader") {
button("Settings") { open_window(settings) }
}
window settings("Settings", open: false) {
button("Done") { close_window(settings) }
}
Opening a window that’s open brings it to the front. A window shows the app’s state as it is whenever it opens, and the user can close any window themselves. The app quits when its last window closes, whether the user or the code closes it.
Elements
Inside a window you put elements: built-in pieces of UI like label, button and
column. They all look like a call:
label("Hello", style: .title)
- Unnamed arguments come first (
"Hello"), then named ones (style: .title). - Parentheses are always needed, even when empty:
spacer(),column() { … }.
Many elements take a block in { } right after the closing parenthesis, on the
same line. What the block means depends on the element:
| Element | Its block holds |
|---|---|
window, column, row, scroll | the children to show inside it |
button | the action: code that runs when it’s clicked |
text_field | an optional action: code that runs when Return is pressed in it |
drop_target | an optional action: code that runs after something is dropped |
column() { // children
button("Save") { save() } // action
}
The elements are window, column, row, scroll, label, button, text_field,
checkbox, switch, slider, picker, nav_item, image, progress, spacer,
divider, context_menu, drop_target, menu and menu_item. The
UI elements reference lists every one with all its settings.
Menus and shortcuts
Beside its windows, an app can have menus, each a top-level menu holding menu_items,
dividers and submenus. An item runs its block when the user chooses it, or presses its
shortcut:
menu("File") {
menu_item("New Download", shortcut: "n") { new_download() }
divider()
menu("Sort By") {
menu_item("Name") { sort = "name" }
menu_item("Size") { sort = "size" }
}
}
A shortcut always uses Cmd on macOS and Ctrl on Windows, so "n" is Cmd+N on a Mac and
Ctrl+N on a PC; add shift+ or alt+ before the key for more. See
menu for where the menus go on each platform.
A stack can have a menu of its own, shown when the user right-clicks it: put a
context_menu among its children, holding the same items, dividers and submenus. Its items
take no shortcut, and run with the names around them, so in a list each row’s menu acts on
its own item:
for item in downloads {
row() {
context_menu() {
menu_item("Download Again") { start(item.url) }
}
label(item.url)
}
}
Dropping files and links
A stack can take files and links the user drags onto it from the Finder, File Explorer
or a browser: put a drop_target among its children, bound to a [string]. A drop writes
a path for each file and an address for each link into it, then runs the block:
state dropped: [string] = []
column() {
drop_target($dropped) {
for link in dropped {
download(link, to: "~/Downloads", progress: $percent, status: $status)
}
}
label("Drop links here")
}
Showing things conditionally
Use if inside the UI to show elements only sometimes:
if todos.is_empty() {
label("Nothing to do!")
} else {
label("{remaining()} left")
}
Showing a list
Use for to show elements once per item:
for todo in todos {
checkbox(checked: $todo.done, title: todo.title)
}
for index, todo in todos also gives each row its position, and for i in 0..count
shows elements a number of times; see
Positions and counting.
Only UI goes in the UI
A window or view body contains elements, views, if and for. It can’t contain
statements like let or count = 0. Those go in functions or button actions:
Error: only elements, `if` and `for` can appear here; put logic in a function or a button's action
Views: your own elements
When a part of the UI gets big, or you use it more than once, give it a name with
view:
view Header(left: int) {
row(align: .center) {
label("Todos", style: .title)
spacer()
label("{left} left", style: .caption)
}
}
Then use it like any element, giving its parameters by name:
window("Todos") {
column() {
Header(left: remaining())
}
}
By convention view names start with a capital letter (Header), while elements,
functions and values use snake_case (text_field, add_todo).
A view shows what it’s given. When remaining() changes, the Header is rebuilt with
the new value.
Literal vs. live values
Some settings must be written directly in the code, because they shape the window rather than show data:
- layout numbers like
spacing: 12andwidth: 320, - options like
style: .titleandalign: .center, - a window’s title, and a text field’s placeholder.
Text that’s shown, like a label’s text, a button’s title or a checkbox’s title, can be any expression, and stays up to date.
label("{count}") // ✓ live text
window(title_of_app()) { } // ✗ the window title must be written out: window("Todos")
Layout
Nifty arranges your UI with two containers:
columnstacks children top to bottom.rowplaces children left to right.
Put rows inside columns (and columns inside rows) to build any layout.
column(spacing: 12, padding: 20) {
label("Title", style: .title)
row(spacing: 8) {
button("Cancel") { }
button("OK", style: .primary) { }
}
}
Spacing and alignment
Both column and row take the same optional settings. These four place their
children:
| Setting | What it does | Example |
|---|---|---|
spacing | space between children, in points | spacing: 8 |
padding | space around the edge, in points | padding: 20 |
align | where children sit across the stack | align: .center |
justify | where children sit along the stack | justify: .space_between |
Numbers must be between 0 and 10000.
align and justify, explained
The easy way to remember: justify works in the direction the stack flows; align works the other way.
justify moves things… | align moves things… | |
|---|---|---|
column | up and down | left and right |
row | left and right | up and down |
align options:
| Option | Children… |
|---|---|
.stretch (default) | stretch to fill the width of a column (or height of a row) |
.start | sit at the left of a column (or top of a row) |
.center | sit in the middle |
.end | sit at the right of a column (or bottom of a row) |
justify options:
| Option | Children… |
|---|---|
.start (default) | bunch up at the start |
.center | bunch up in the middle |
.end | bunch up at the end |
.space_between | spread out, with the first and last at the edges |
Pushing things apart with spacer
spacer() takes up all the leftover space. Use it to push things to opposite ends:
row() {
label("Todos", style: .title)
spacer()
label("3 left", style: .caption)
}
The title sits on the left and the count on the right.
Scrolling with scroll
scroll stacks its children top to bottom, like column, and lets the user scroll them
up and down when they don’t fit. Like spacer, it takes the space its parent has left,
so put it where there is space to give: straight in a window, or in a stack with
grow: true.
window("Downloads", width: 400, height: 300) {
column(spacing: 8, padding: 16) {
label("Downloads", style: .title)
scroll(spacing: 4) {
for item in downloads {
label(item.url)
}
}
}
}
The title stays put, and the list below it scrolls in the rest of the window. A scroll area inside a stack that fits its content gets no space at all.
Separating sections with divider
divider() draws a thin line across the stack, to separate groups of elements.
Centering everything
To put content in the middle of a window, center on both axes:
column(align: .center, justify: .center) {
label("Right in the middle")
}
Sizes
A stack fits its content unless you say otherwise:
| Setting | What it does | Example |
|---|---|---|
width | a fixed width, in points | width: 200 |
max_width | fill the width on offer, up to this many points | max_width: 600 |
grow | take the space the parent has left along its direction | grow: true |
A window can’t be made narrower than its content, so a fixed-width sidebar and a text field never get squashed.
Sidebars, panes and cards
surface: draws a stack on one of the system’s own backgrounds. Here is a whole app
with a sidebar, a main pane and a card:
state section = "inbox"
state unread = 3
window("Mail", width: 720, height: 460) {
row() {
column(surface: .sidebar, width: 200, padding: 10) {
label("Mailboxes", style: .caption)
nav_item("Inbox", value: "inbox", selection: $section, icon: .inbox, badge: "{unread}")
nav_item("Sent", value: "sent", selection: $section, icon: .send)
nav_item("Archive", value: "archive", selection: $section, icon: .archive)
}
column(surface: .content, grow: true, padding: 24, align: .center) {
column(max_width: 480, spacing: 12) {
row(align: .center) {
label("Mail", style: .large_title)
spacer()
button("Mark all read", style: .icon, icon: .check) { unread = 0 }
}
column(surface: .card, padding: 16, spacing: 4) {
label("Welcome to {section}", style: .headline)
label("Cards group related content on the page.", color: .secondary)
}
}
}
}
}
.sidebaris the side panel. On macOS it’s translucent and runs up under the title bar, like Finder’s. Fill it withnav_itemrows, which highlight the oneselectionholds..contentis the main pane, in the color content is read on..cardis a rounded, outlined panel that groups related content.
grow: true lets the main pane take the rest of the window. max_width: 480 inside a
pane with align: .center keeps the text a comfortable width, centred, when the window
is maximized. See column for how each surface
looks on each platform.
Text sizes
label has four text styles. They use your system’s own fonts, so they follow
accessibility text sizes and dark mode automatically.
| Style | Use it for |
|---|---|
.large_title | the name of what the window shows |
.title | the main heading |
.headline | a bold section heading |
.body (default) | normal text |
.caption | small, quieter text |
To change a label’s color, use color: with one of the system’s colors, such as
.secondary, .accent or .danger. To place its text, use align: with .start,
.center or .end. See label for the full list.
Two-way bindings
Most of the UI only shows values. A label shows text; it can’t change it. But some controls let people edit a value: a text field you type into, a checkbox you click.
For those, you hand over the value with a $ in front. That’s a two-way binding:
state draft = ""
state agreed = false
window("Form") {
column(spacing: 8, padding: 16) {
text_field($draft, placeholder: "Your name")
checkbox(checked: $agreed, title: "I agree")
label("Hello, {draft}!")
}
}
The two ways are:
- State → control: the field shows
draft. If code changesdraft(say, a button setsdraft = ""), the field changes too. - Control → state: when someone types,
draftchanges. The label above updates as they type.
What can be bound
| Control | Binds to |
|---|---|
text_field($value) | a string |
checkbox(checked: $value) | a bool |
download(…, progress: $value, status: $text) | an int and a string; received:, total: and speed: each an int |
pick_file($path), pick_folder($path), pick_save_path($path) | a string |
pick_files($paths) | a [string] |
text_field and checkbox are the only elements that take a binding. The built-in
function download takes two as well, and up to
three more, and writes to them as the download goes. The
file dialogs
take one, and write the path the user chose to it when the dialog closes.
The thing after $ must be somewhere a value is stored, starting from state:
$draft // ✓ a state
$todo.done // ✓ a field of a `for` item looping over state
$settings.dark_mode // ✓ a field of a state struct
$todos[0].done // ✓ a field of a list item in state
$remaining() // ✗ a function's result isn't stored anywhere
Binding inside a loop
Inside for todo in todos, todo stands for the real item in the list, so binding
to it edits the list in state:
for todo in todos {
checkbox(checked: $todo.done, title: todo.title)
}
Clicking the third checkbox changes todos[2].done.
Forgetting the $
If you leave out the $, Nifty tells you:
Error: `text_field` needs a two-way binding to a `string`, such as `$name`
How the UI stays up to date
You’ve seen that in Nifty you never redraw anything by hand. This page explains what’s happening, so the behavior never surprises you.
The short version
- Before your app runs, Nifty finds out which state each part of the UI reads.
- When state changes, Nifty updates only the parts that read it.
That’s all. No diffing a whole tree, and no virtual DOM.
An example
state count = 0
state name = "Ada"
window("Demo") {
column() {
label("Hi, {name}") // reads name
label("Count: {count}") // reads count
if count > 5 { // reads count
label("Big number!")
}
button("+") { count += 1 }
}
}
When you click +:
countchanges.- The second label reads
count, so it gets its new text. - The
ifreadscount, so Nifty checks it again and shows or hides “Big number!”. - The first label only reads
name, so it isn’t touched at all.
It looks inside functions
If the UI calls a function, Nifty follows it, and anything it reads counts too:
fn remaining() -> int {
let count = 0
for todo in todos {
if !todo.done {
count += 1
}
}
return count
}
window("Todos") {
label("{remaining()} left") // updates when todos changes
}
What gets updated, and how
| Part of the UI | When what it reads changes… |
|---|---|
| Text (label text, button title, …) | the native widget gets its new text |
A bound control (text_field, checkbox) | it shows the new value |
if | it’s checked again; its contents are rebuilt in place if needed |
for | its rows are rebuilt in place |
| A view | it’s rebuilt in place with its new parameters |
Everything else on screen stays exactly as it was, including what’s typed in other text fields and where the cursor is.
When changes happen
Changes only happen in response to something: a button click, or typing into a bound control. The changes in one click all happen together, and then the screen catches up once.
Showing the UI is never allowed to change state (see State). That’s what makes this work: drawing is just reading, so Nifty can redraw any part at any time and always get the same answer.
If something goes wrong while running
If a button’s action hits an error, like dividing by zero or reading a list item that
isn’t there, the app stops and nf run shows the line that caused it. Nifty stops
rather than carrying on, because the action was interrupted halfway and the app’s state
might no longer make sense.
nf dev is the exception: while you’re developing, it shows the error and keeps the app
open, so you can fix the code and save.
Reloading with nf dev
When nf dev reloads your app after a save, each state keeps its value as long as the
new code still declares it with the same name and type. Everything on screen is then
built from those values with the new code. So you can add a todo, tweak the layout,
save, and still see your todo. See nf dev.
Reading error messages
Nifty tries hard to explain problems in plain words and to suggest a fix. This page shows how to read an error, then lists the ones you’re most likely to meet.
Anatomy of an error
Error: `label` shows text; turn this `int` into text with "{…}"
╭─[ hello/main.nf:5:11 ]
│
5 │ label(count)
───╯
1 error
- The first line says what’s wrong, and usually how to fix it.
hello/main.nf:5:11is the file, the line (5) and the column (11).- The code below is the line itself.
Nifty reports every problem it finds in one go, not just the first, and the count is at the bottom. In an editor with Nifty support, the same errors appear as squiggles while you type.
When errors appear
| When | What finds it | Example |
|---|---|---|
| Before running | nf check, nf run, nf dev and nf build | a typo in a name, the wrong type |
| While running | nf run and nf dev | dividing by zero, a list item that isn’t there |
Most mistakes are caught before running. When an error happens while running, nf run
stops the app and points at the line, just like a check error. nf dev points at the
line too, but keeps the app open so you can save a fix.
Common errors
`label` shows text; turn this `int` into text with "{…}"
You gave label a number. Put it in a string with interpolation:
label(count) // ✗
label("{count}") // ✓
can't use `+` with `string` and `int`
You joined text and a number with +. Use interpolation instead:
label("Count: " + count) // ✗
label("Count: {count}") // ✓
can't use `+` with `int` and `float`
int and float don’t mix. Write both the same way: 2.0 * 1.5, or 2 * 3.
expected end of line, found `{`; put each statement on its own line
Usually a missing () on an element:
column { … } // ✗
column() { … } // ✓
`button` needs a `{ … }` block with what it does
Every button needs an action, even an empty one:
button("Later") // ✗
button("Later") { } // ✓
`text_field` needs a two-way binding to a `string`, such as `$name`
Controls that edit a value need a $. See Two-way bindings.
text_field(draft) // ✗
text_field($draft) // ✓
unknown name `count`
There’s no state, variable or parameter with that name. Check the spelling, and check
that you declared it with state or let.
can't tell what this empty list holds; add a type such as `: [int]`
An empty list needs its type written out:
state todos = [] // ✗
state todos: [Todo] = [] // ✓
only declarations, `window(…)` and `menu(…)` can appear at the top level
Outside any window or function, you can only declare things: state, fn, view,
struct, window and menu. Use state (not let) for top-level values.
only elements, `if` and `for` can appear here; put logic in a function or a button's action
You wrote a statement like count = 0 directly in a window or view. Move it into a
button’s action or a function.
showing the UI can't change state; do that in a button's action
Something used to show the UI tried to change state, often a function call inside a label. See State.
a state's starting value can't use other state
state b = a isn’t allowed. Use a function that computes b from a instead.
`remaining` can reach its end without returning a `int`
Some path through the function finishes without a return. Often an if without an
else; add a return at the end.
there is no item 3; the list has 2 (while running)
You read a list position that doesn’t exist. Positions start at 0, so a list of two
has items 0 and 1. Check items.len() first.
can't divide by zero (while running)
Check the number you divide by isn’t 0 before dividing.
Still stuck?
Open an issue on GitHub with your code and the error. Confusing error messages count as bugs.
Cheat sheet
All of Nifty on one page. Each heading links to the full explanation.
Declarations
state count = 0 // app state; the UI follows it
state todos: [Todo] = [] // empty lists need a type
struct Todo { // a record type
title: string
done: bool
}
fn add(a: int, b: int) -> int { // a function
return a + b
}
view Badge(text: string) { // your own element
label(text, style: .caption)
}
window("App", width: 400, height: 300) { // a window
Badge(text: "hi")
}
Types
int · float · bool · string · [T] (a list) · struct names
Statements
let total = a + b // local variable
count = 0 // assign
count += 1 // also -= *= /=
if x > 0 { } else if x < 0 { } else { }
for item in items { }
for index, item in items { } // index: 0, 1, 2, …
for i in 0..n { } // 0 up to n - 1
return value
add_todo() // a call
Expressions
"Hi {name}, you have {count * 2} points" // interpolation
a || b a && b !a // logic
== != < <= > >= // comparison
+ - * / % // math (+ also joins strings)
items[0] todo.title // index, field
Todo(title: "x", done: false) // make a struct
[1, 2, 3] // a list
.center // an option
$draft // a two-way binding
Lists and strings
items.len() items.is_empty()
items.push(x) items.remove(i)
text.len() text.is_empty()
UI elements
window("Title", width: 480, height: 320) { … } // top level only
window("HUD", backdrop: .translucent) { … } // .standard .translucent .solid
window settings("Settings", open: false) { … } // named; opens when code asks
column(spacing: 12, padding: 20, align: .center, justify: .start) { … }
row(spacing: 8, align: .center) { … }
column(surface: .sidebar, width: 200) { … } // .none .sidebar .content .card
column(surface: .content, grow: true) { … } // grow fills the space left
column(max_width: 600) { … } // fills up to 600 points wide
scroll(spacing: 8) { … } // a column in the space left, scrolling
label("Text", style: .title) // .body .large_title .title .headline .caption
label("Oops", color: .danger, align: .center, selectable: true)
button("Save", style: .primary) { … } // .normal .primary .plain .icon
button("Delete", style: .icon, icon: .trash) { … }
button("All", selected: filter == "all") { filter = "all" } // one of a few choices
nav_item("Inbox", value: "inbox", selection: $section, icon: .inbox, badge: "3")
text_field($draft, placeholder: "Type…")
text_field($notes, lines: 4) // several lines, wrapping and scrolling
text_field($query) { search() } // Return runs the block
image("~/Pictures/a.png", width: 64) // keeps its shape; full paths or ~ only
progress(value: percent) // 0 to 100; progress() when you can't say
checkbox(checked: $done, title: "Done")
switch(on: $notify, title: "Notify me")
slider($volume, min: 0, max: 10)
picker($speed, options: ["Fast", "Slow"])
spacer()
divider()
align: .stretch .start .center .end (on label: .start .center .end)
color: .primary .secondary .tertiary .accent .success .warning .danger
justify: .start .center .end .space_between
Menus
menu("File") { // top level: a menu of the menu bar
menu_item("New", shortcut: "n") { … } // Cmd+N on macOS, Ctrl+N on Windows
divider()
menu("Sort By") { // a submenu
menu_item("Name", shortcut: "shift+1") { … }
}
}
row() {
context_menu() { // shown when the row is right-clicked
menu_item("Remove") { … } // no shortcut in a context menu
}
drop_target($dropped) { … } // paths and links dropped on the row
label(item.name)
}
Built-in functions
download(url, to: "~/Downloads", progress: $percent, status: $status)
download(url, to: folder, progress: $p, status: $s, received: $got, total: $size, speed: $rate)
download(url, to: folder, id: $items[last].id, progress: $items[last].percent, status: $items[last].status)
download(url, to: folder, progress: $p, status: $s, connections: 4) // splits the file across 4 connections
download(url, to: folder, progress: $p, status: $s, name: "report.pdf") // never replaces a file: "report (1).pdf"
download(url, to: folder, progress: $p, status: $s, headers: ["Authorization: Bearer {token}"], retries: 3)
pause_download(item.id) // keeps the .part file; status becomes "Paused"
resume_download(item.id) // asks for the rest; starts over if the server can't send part
cancel_download(item.id) // removes the partial file; status becomes "Cancelled"
format_bytes(1500) // "1.5 KB"; the one built-in the UI can call
pick_file($path, title: "Open", types: ["png", "jpg"], from: "~/Pictures")
pick_files($paths)
pick_folder($folder)
pick_save_path($path, name: "export.csv", types: ["csv"])
open_window(settings) close_window(settings)
UI logic
if todos.is_empty() {
label("Nothing to do")
}
for todo in todos {
checkbox(checked: $todo.done, title: todo.title)
}
The nf command
nf create my-app # new project
nf run # check, then run
nf dev # run, and reload on every save
nf check # check only
nf build # make an app in build/ (.app on macOS, .exe folder on Windows)
Every .nf file in the project folder is part of the app, with no imports: a view
declared in ui/header.nf can be used in main.nf.
Rules of thumb
- One statement per line. No semicolons.
- Elements always have parentheses:
column() { },spacer(). - Turn numbers into text with
"{n}", not+. intandfloatdon’t mix.- Only button actions, bindings,
downloadand its controls, and thepick_…dialogs change state.
UI elements
Every built-in element, with all its settings. Settings marked required must be given; the rest are optional.
Kinds of value:
- text: any expression that gives a
string; stays up to date, e.g."{count} left". - fixed text: a string written directly, without
{…}, e.g."Todos". - number: a number written directly, between
0and10000, e.g.12. - fixed int: a whole number written directly, between
-1000000000and1000000000, e.g.-10. - int: any expression that gives an
int; stays up to date, e.g.item.percent. - list of text: any expression that gives a
[string]; stays up to date. - option: one of a fixed set of
.names, e.g..center. - flag:
trueorfalse, written directly. - binding: a
$place, e.g.$draft. See Two-way bindings.
Unnamed settings come first, in order; named ones follow in any order.
window
A window. Only at the top level of a file. An app needs at least one.
window("Todos", width: 360, height: 480) { … }
window settings("Settings", open: false) { … }
| Setting | Kind | |
|---|---|---|
| (first) title | fixed text | required |
width | number | default 480 |
height | number | default 320 |
backdrop | option | .standard (default), .translucent, .solid |
open | flag | true (default): it opens when the app starts; false: it waits for code to open it, and needs a name |
A name written between window and the title, as in window settings("Settings"), is
what open_window and close_window
open and close it by. Window names share the program’s namespace with its states,
functions, views and structs. An app needs at least one window that opens when it
starts.
width and height are the size of the content area. The window can’t be made
narrower than its content needs: it opens wider if the content doesn’t fit, and the user
can’t drag it narrower. It can be made as short as the user likes, because a long list
would otherwise make the window taller than the screen.
On macOS the title bar is transparent and the content area runs underneath it, the way Finder and Mail look. Content still starts below the title bar; only a surface that starts at the top of the window reaches up under it. On Windows the title bar sits above the content area, as usual.
Backdrops are drawn by the system, so they cost the app nothing:
backdrop | macOS | Windows 11 |
|---|---|---|
.standard | the usual window | Mica in the title bar and frame |
.translucent | the whole window shows a blurred view of what’s behind it, where no surface covers it | Acrylic in the title bar and frame |
.solid | the usual window | no material: a solid title bar and frame |
On Windows the material stays in the title bar and frame, and the content area keeps its
solid Fluent colors. The system’s own controls can’t yet draw on a see-through content
area. Windows 10, Windows 11 before version 22H2, and high contrast themes draw every
backdrop as .solid.
Block: the window’s content (required).
column
Stacks its children top to bottom. See Layout.
column(spacing: 12, padding: 20, align: .center) { … }
| Setting | Kind | |
|---|---|---|
spacing | number | space between children |
padding | number | space around the edge |
align | option | .stretch (default), .start, .center, .end |
justify | option | .start (default), .center, .end, .space_between |
surface | option | .none (default), .sidebar, .content, .card; what the stack is drawn on |
width | number | a fixed width; without it the stack fits its content |
max_width | number | the stack fills the width it’s given, up to this |
grow | flag | false (default); true takes the space the parent has left along its direction |
Surfaces are drawn behind the stack’s children with each platform’s own materials, so they follow dark mode and high contrast:
| Surface | Use it for | macOS | Windows |
|---|---|---|---|
.sidebar | a window’s side panel | translucent sidebar material that shows the desktop through it | a solid panel a shade apart from the window |
.content | the window’s main pane | the color content is read on: white, or dark in dark mode | Fluent’s content layer color |
.card | grouping related content | a rounded panel with a thin outline | a rounded panel with a thin outline |
A surface that starts at the top of the window reaches up under the macOS title bar, so a sidebar runs the full height of the window. Its children still start below the title bar.
max_width keeps text readable when a window is maximized: put the stack in a parent with
align: .center and it stays centred at that width, or narrower when the window is.
row() {
column(surface: .sidebar, width: 200, padding: 12) { … }
column(surface: .content, grow: true, align: .center) {
column(max_width: 600, padding: 24) { … }
}
}
Block: the children.
row
Places its children left to right. Same settings as column.
row(spacing: 8, align: .center) { … }
Block: the children.
scroll
Stacks its children top to bottom, like column, and scrolls them up and down
when they are taller than the space it has.
scroll(spacing: 8, padding: 12) {
for item in downloads {
DownloadRow(item: item)
}
}
Same settings as column, but grow: a scroll area always takes the space
its parent has left along its direction, like spacer, and stretches across
it. It adds nothing to its parent’s size, so put it straight in a window, or in a stack
that has space to give, such as one with grow: true; in a stack that fits its content
it gets no space. A surface stays put behind the content as it scrolls.
| macOS | Windows | |
|---|---|---|
| Scrolling | the trackpad, mouse wheel or scroll bar, with the system’s elastic bounce | the mouse wheel, by as many lines a notch as the user set, or the scroll bar |
| Scroll bar | floats over the content, and shows while scrolling; with Show scroll bars: Always, it takes a strip beside the content and always shows | takes a strip beside the content, and always shows, greyed out when there’s nothing to scroll |
On Windows, moving the keyboard focus to a control that’s scrolled out of view doesn’t yet scroll it into view.
Block: the children.
label
Shows text.
label("{left} left", style: .caption)
label("Couldn't save", color: .danger, align: .center)
label(token, selectable: true)
| Setting | Kind | |
|---|---|---|
| (first) text | text | required |
style | option | .body (default), .large_title, .title, .headline, .caption |
color | option | .primary, .secondary, .tertiary, .accent, .success, .warning, .danger |
align | option | .start (default), .center, .end |
selectable | flag | false (default); true lets the user select and copy the text |
color defaults to .secondary for .caption and .primary for every other style.
The colors are the system’s own, so they follow the user’s accent color, dark mode and
high contrast on macOS and Windows, and change while the app runs when the user switches.
align places the text within the label’s frame, so it only shows when the label is
wider than its text, as it is in a column with the default align: .stretch. .start
and .end follow the reading direction: in a right-to-left language, .start is the
right edge.
Block: none.
button
Runs its block when clicked.
button("Add", style: .primary) { add_todo() }
button("Add", icon: .plus) { add_todo() }
button("Delete", style: .icon, icon: .trash) { delete(todo.id) }
| Setting | Kind | |
|---|---|---|
| (first) title | text | required |
style | option | .normal (default), .primary, .plain, .icon |
icon | option | an icon shown before the title |
selected | bool | shows the button as selected while it holds; stays up to date |
| Style | Looks like |
|---|---|
.normal | a standard push button |
.primary | the window’s main action, in the accent color: pressing Return clicks it, unless the focus is in a text field with its own action |
.plain | borderless until the pointer is over it, in the accent color, for quieter actions such as those in a row |
.icon | borderless until the pointer is over it, showing only its icon; the title becomes its tooltip and what screen readers say, so always give one |
With several primary buttons on Windows, Return clicks the newest one still showing.
Picking one of a few choices. Give each choice a button whose selected says whether
it is the current one, and whose action picks it:
state filter = "all"
row(spacing: 4) {
button("All", selected: filter == "all") { filter = "all" }
button("Active", selected: filter == "active") { filter = "active" }
button("Done", selected: filter == "done") { filter = "done" }
}
Clicking a button never selects it by itself: it shows whatever selected says once its
action has run. A .primary button can’t take selected, since it is the window’s
default action.
| macOS | Windows | |
|---|---|---|
Selected .normal | the system’s on-off push button, shown on | Fluent’s checked toggle button, in the accent color |
Selected .plain, .icon | an on-off accessory bar button, shown on | a subtle fill, with the title or icon in the accent color |
| Screen readers | an on-off button | a push-like checkbox, announced as pressed while selected |
Block: the action (required). It may change state.
text_field
A text box, bound to a string: one line, or several that wrap and scroll.
text_field($draft, placeholder: "What needs doing?")
text_field($notes, lines: 4)
| Setting | Kind | |
|---|---|---|
| (first) value | binding to a string | required |
placeholder | fixed text | grey hint shown while empty; one-line fields only |
lines | fixed int | how many lines tall, from 1 (default) to 100 |
A one-line field can take a block, which runs when the user presses Return in it,
in place of pressing the window’s .primary button:
text_field($query, placeholder: "Search") { search() }
A field with more than one line wraps its text, scrolls when the text is longer than it
is, and takes Return as a new line. Lines in the bound text end in \n on both
platforms. Neither platform’s multi-line text box shows a placeholder, so placeholder
goes only with one-line fields.
| macOS | Windows | |
|---|---|---|
| One line | the system’s text field | the system’s edit control |
| Several lines | a text view in a bordered scroll view, as in Mail’s message body | a multi-line edit control with a scroll bar |
| Tab in several lines | types a tab; Ctrl+Tab moves to the next control | the same |
Block: the action Return runs, for a one-line field; optional. It may change state.
checkbox
A tick box, bound to a bool.
checkbox(checked: $todo.done, title: todo.title)
| Setting | Kind | |
|---|---|---|
checked | binding to a bool | required |
title | text | the label next to the box |
Block: none.
switch
Turns a bool on and off, like a checkbox, with the switch at the end of
its title, as in a settings window.
switch(on: $notify, title: "Notify me when downloads finish")
| Setting | Kind | |
|---|---|---|
on | binding to a bool | required |
title | text | shown at the start of the row |
The title sits at the start of the row and the switch at its end, so in a column with
the default align: .stretch the switches of several rows line up.
| macOS | Windows | |
|---|---|---|
| Switch | the system’s NSSwitch | Fluent’s toggle switch, drawn by Nifty on a real checkbox, so screen readers announce it as a checkbox |
Block: none.
slider
Picks a whole number by dragging a knob along a track.
slider($volume, min: 0, max: 10)
| Setting | Kind | |
|---|---|---|
| (first) value | binding to an int | required |
min | fixed int | the left end; default 0 |
max | fixed int | the right end; default 100, and no lower than min |
The knob slides smoothly and writes the whole number nearest it each time that number
changes. A slider has no natural width: it stretches across a column with the default
align: .stretch, and is 100 points wide where it can’t stretch.
| macOS | Windows | |
|---|---|---|
| Slider | the system’s NSSlider | the system’s trackbar, drawn by Nifty in Fluent’s style; the arrow keys and Page Up and Page Down move it too |
Block: none.
picker
Picks one string from a list, in a pop-up menu.
state speeds = ["Unlimited", "1 MB/s", "500 KB/s"]
state speed = "Unlimited"
picker($speed, options: speeds)
| Setting | Kind | |
|---|---|---|
| (first) selection | binding to a string | required; the option shown, and where a pick goes |
options | list of text | required; stays up to date |
The picker shows the option equal to selection, or nothing when no option is. It is as
wide as its widest option, so it doesn’t change size as the pick changes.
| macOS | Windows | |
|---|---|---|
| Picker | the system’s pop-up button | a button drawn by Nifty as Fluent’s combo box, opening Win32’s own pop-up menu; screen readers announce it as a button named by the option it shows |
Win32’s menus follow the system’s light look, also in dark mode, as other classic Windows menus do.
Block: none.
nav_item
A row of a sidebar list, like those in Finder or File Explorer: an icon, a title, and a
badge such as a count at the end. It’s highlighted while selection holds its value,
and picking it puts value there.
state section = "inbox"
nav_item("Inbox", value: "inbox", selection: $section, icon: .inbox, badge: "{unread}")
nav_item("Sent", value: "sent", selection: $section, icon: .send)
| Setting | Kind | |
|---|---|---|
| (first) title | text | required |
value | text | required; what selection holds while this item is picked |
selection | binding to a string | required; shared by the items of one list |
icon | option | an icon, drawn in the accent color |
badge | text | shown at the end in a quieter color; empty shows nothing |
Put nav items one after another in a column(surface: .sidebar); they need no
spacing, as each row is the platform’s own height.
| macOS | Windows | |
|---|---|---|
| Row | 28 points, Finder’s sidebar row | 32 pixels at 100%, Fluent’s navigation item |
| Selected | a rounded highlight in the system’s selection color | a subtle highlight with an accent mark at its start |
| Under the pointer, pressed | a fainter highlight, and a stronger one while pressed | the same, in Fluent’s subtle fills |
Block: none.
image
Shows the image in a file, as large as fits its frame, keeping its shape.
image("~/Pictures/cover.png", width: 120)
image(item.thumbnail, width: 48, height: 48)
| Setting | Kind | |
|---|---|---|
| (first) path | text | required; where the file is; stays up to date |
width | number | a fixed width; the height follows the image’s shape |
height | number | a fixed height; the width follows the image’s shape |
With both width and height, the image fits inside that box, centred, keeping its
shape. With neither, it shows at its own size, a pixel to a point.
The path must be a full one, such as /Users/ada/Pictures/a.png or
C:\Users\ada\Pictures\a.png, or start with ~ for the home folder. Images that come
with an app, found next to its code, aren’t supported yet, so a relative path written
directly is an error. A path that names no file, or a file that isn’t an image, shows
nothing, at the size given; so does an empty path. The image is read again whenever the
path changes.
| macOS | Windows | |
|---|---|---|
| Formats | whatever the system reads: PNG, JPEG, GIF, HEIC, TIFF, PDF and more | what the Windows Imaging Component reads: PNG, JPEG, GIF, BMP, TIFF and ICO, and more with codecs installed |
| Drawing | the system’s image view, scaling smoothly | scaled smoothly by the Windows Imaging Component and blended with its transparency |
Block: none.
progress
A bar that shows how far along some work is. With a value it fills to that percentage;
without one it animates to show that work is under way but can’t say how far.
progress(value: item.percent)
progress()
| Setting | Kind | |
|---|---|---|
value | int | how far along, from 0 (empty) to 100 (full); stays up to date |
A value below 0 shows an empty bar, and one above 100 a full one. Whether a bar has
a value is fixed where it’s written; to switch between the two, show one or the other
with if.
A progress bar has no natural width: it stretches across a column with the default
align: .stretch, and is 100 points wide where it can’t stretch.
| macOS | Windows | |
|---|---|---|
With value | the system’s bar, in the accent color | the system’s progress bar |
| Without | the bar’s animated stripe | the bar’s marquee, sweeping across it |
Block: none.
spacer
Takes up all the leftover space in a row or column, pushing its neighbours apart.
spacer()
No settings. Block: none.
divider
A thin line, to separate groups of elements.
divider()
No settings. Block: none.
context_menu
A menu shown when the user right-clicks the stack it is in, written among the stack’s children.
for item in downloads {
row() {
context_menu() {
menu_item("Download Again") { start(item.url) }
divider()
menu("Copy") {
menu_item("Address") { copied = item.url }
}
}
label(item.url)
}
}
No settings. It takes no room and draws nothing: it covers its whole stack, padding
included, wherever it is written among the children. Where stacks with context menus
sit inside each other, a right-click shows the menu of the innermost one under the
pointer. A stack has one context_menu, and one can’t be written straight inside a for,
where it would cover the stack around the loop once per item; put it in the row or
column the loop shows for each item instead.
Its block holds the same entries as a menu: menu_items, divider()s and
submenus. The items take no shortcut, since a shortcut can’t say which stack it means,
and their actions see the names around the menu, such as a for loop’s item.
| macOS | Windows | |
|---|---|---|
| Opens on | a right-click or a Control-click | a right-click, or Shift+F10 or the Menu key while a control in the stack has the keyboard, which shows it at the control |
| Text fields and selectable labels | keep their own menu, with Copy and the like | keep their own menu, with Copy and the like |
Block: the entries (required).
drop_target
Takes files and links the user drags onto the stack it is in, from the Finder, File Explorer, a browser or another app, written among the stack’s children.
state dropped: [string] = []
scroll() {
drop_target($dropped) { download_all() }
label("Drop links here")
}
| Setting | Kind | |
|---|---|---|
| (first) dropped | binding to a [string] | required; what was dropped, in order: a full path for each file or folder, and the address for each link |
Like a context_menu, it takes no room and draws nothing: it covers its
whole stack, padding included. Where stacks with drop targets sit inside each other, a
drop goes to the innermost one under the pointer. A stack has one drop_target, and one
can’t be written straight inside a for.
Each drop replaces what the binding held, then runs the block, so the block sees the new list. While something that can be dropped is dragged over the area, the pointer shows a copy; anything else, such as plain text, isn’t taken.
| macOS | Windows | |
|---|---|---|
| Files | from the Finder and other apps, as /Users/… paths | from File Explorer and other apps, as C:\… paths |
| Links | dragged from a browser’s address bar or page, as their address | from browsers that give a dragged link as its address, as Edge, Chrome and Firefox do |
| Text fields | a text field under the pointer takes the drop itself, as text | a text field under the pointer takes the drop itself, as text |
Block: an action run after each drop (optional). It may change state.
menu
A menu of the app’s menu bar, written at the top level beside its windows, or a submenu, written inside another menu.
menu("Downloads") {
menu_item("Pause All", shortcut: "shift+p") { pause_all() }
divider()
menu("Sort By") {
menu_item("Name") { sort = "name" }
}
}
| Setting | Kind | |
|---|---|---|
| (first) title | fixed text | required |
The block holds the menu’s entries, in order: menu_items, divider()s, which draw a
line between groups of items, and submenus. Menus can’t hold if or for; an item’s
action decides what to do when it runs.
| macOS | Windows | |
|---|---|---|
| Where | the menu bar at the top of the screen: the app menu with Quit, your first menu, the Edit menu with Undo, Cut, Copy, Paste and Select All, then the rest of your menus | the menu bar of every window, holding your menus in order; text fields take their editing shortcuts themselves |
| Look | the system’s own | Win32’s own, which stays light in dark mode, as classic Windows menu bars do |
Block: the entries (required).
menu_item
An item of a menu, which runs its block when the user chooses it or presses its
shortcut.
menu_item("New Download", shortcut: "n") { new_download() }
| Setting | Kind | |
|---|---|---|
| (first) title | fixed text | required |
shortcut | fixed text | a key, after shift+ or alt+ if any, such as "n", "shift+n" or "alt+return" |
A shortcut always uses the platform’s command key: Cmd on macOS, Ctrl on Windows. Keys
are the letters, the digits, , . / ; ' [ ] - =, and return, delete, up, down,
left and right; alt is Option on macOS. The shortcuts the system’s own commands
use, q (Quit), z and shift+z (Undo and Redo), x, c, v and a (Cut, Copy,
Paste and Select All), are kept for them, and two items can’t share a shortcut.
The shortcut shows beside the item’s title, as each platform writes it: ⇧⌘N on macOS, Ctrl+Shift+N on Windows. It works whenever one of the app’s windows has the keyboard. On Windows, a punctuation key that the user’s keyboard layout doesn’t have gives no shortcut, though the item still shows it.
Block: the action (required). It may change state.
Icons
button and nav_item take icon: with one of these:
.plus, .trash, .star, .star_fill, .check, .check_circle, .circle, .home, .inbox, .archive, .list, .calendar, .sun, .flag, .close, .gear, .search, .mail, .send, .folder, .clock, .tag, .person, .document, .download, .heart, .bell, .pencil, .share, .info
They come from the system’s own icon set: SF Symbols on macOS, and Segoe Fluent Icons on
Windows 11 (Segoe MDL2 Assets on Windows 10). Nothing is added to your app, and icons
follow the system’s look and the user’s text size. On Windows, .inbox shows the same
envelope as .mail, as the Windows icon fonts have no inbox tray.
Where this comes from: the list of elements is defined once in
crates/nifty-check/src/elements.rs. The checker and the editor’s autocomplete both read it, so they always agree.
Built-in functions
Functions that come with Nifty: download, its controls
pause_download, resume_download and cancel_download,
format_bytes, the
file dialogs, and
open_window and close_window. Call them
from your own functions and from button actions, like any function you write. All but
format_bytes change state, so the UI can’t call them while it is shown; format_bytes
only gives back text, so labels can show it.
download
Saves the file at a web address into a folder. It runs in the background, so the app stays responsive, and reports how it’s going by writing to state: always a percentage and a status, and the bytes received, the file’s size and the speed when you bind them.
state percent = 0
state status = ""
button("Download") {
download("https://example.com/report.pdf", to: "~/Downloads", progress: $percent, status: $status)
}
label("{percent}% {status}")
| Setting | Kind | |
|---|---|---|
| (first) url | string | required; an http:// or https:// address |
to | string | required; the folder to save into, created if missing |
progress | binding to an int | required; the percentage done, 0 to 100 |
status | binding to a string | required; where the download stands, in words |
received | binding to an int | the bytes saved so far |
total | binding to an int | the file’s size in bytes, once the server says; 0 until then |
speed | binding to an int | bytes per second, averaged over the last two seconds |
id | binding to a string | the download’s id, for pausing, resuming and cancelling; needed when the bindings are in a list item |
name | string | the name to save the file as, made safe the same way as a name from the address; "", the default, names it after the address |
connections | int | how many connections fetch the file at once, from 1 (the default) to 16; see Several connections below |
headers | [string] | headers sent with every request, each written "Name: value", such as ["Authorization: Bearer …"]; none by default |
retries | int | how many times in a row a connection is tried again after a failure that may pass; 0 by default; see Retries below |
What the bindings show:
| When | progress | status |
|---|---|---|
| When | progress | status |
| — | — | — |
| right after the call | 0 | Downloading… |
| as data arrives | 0 to 100 | unchanged |
| when it’s saved | 100 | Saved to /Users/you/Downloads/report.pdf, or Saved to C:\Users\you\Downloads\report.pdf on Windows |
| when it fails | unchanged | Failed: and the reason, such as http status: 404 |
| while it waits to retry | unchanged | Retrying… |
| when it’s paused | unchanged | Paused |
| when it’s resumed | as data arrives | Downloading… |
| when it’s cancelled | unchanged | Cancelled |
While data arrives the bindings change at most ten times a second, so a fast download
doesn’t keep the app busy redrawing. progress and total only move while downloading
when the server says how large the file is; otherwise progress jumps to 100 and
total to the file’s size once it is saved. Show sizes and speeds with
format_bytes:
label("{format_bytes(received)} of {format_bytes(total)}, {format_bytes(speed)}/s")
Where the file goes: the file is named after the last part of the address, so
…/files/report.pdf saves as report.pdf, and …/my%20file.pdf as my file.pdf.
name: "Q3 report.pdf" names it yourself instead, written as it is, without %
decoding. Either way, characters a file name can’t hold, such as / or :, become _,
so a name such as ../report.pdf stays in the folder as .._report.pdf. If a file with that
name is already in the folder, the download is saved next to it as report (1).pdf, then
report (2).pdf, and so on; it never replaces a file. A name Windows keeps for devices,
such as con or nul.txt, gets an _ in front, on every system, so the file opens
anywhere. A folder starting with ~ is inside your home folder.
While it downloads, the file is called report.pdf.part, and it gets its name once it is
saved: the first of report.pdf, report (1).pdf and so on that is still free then. If a
download fails or is cancelled, the partly saved file is removed.
Several connections: connections: 4 fetches the file over up to four connections
at once, which is faster from a server that limits each connection’s speed.
download(url, to: folder, progress: $percent, status: $status, connections: 4)
The first connection asks for the whole file as an HTTP range request. When the server
answers with part of the file and says how large it is, the file is split into nearly
equal parts, no smaller than 64 KB each, so a small file still uses one connection: the
first connection keeps the first part, and a new connection asks for each of the others.
They write into the same .part file, each at its own place. When the server can’t send
part of a file, or doesn’t say how large it is, the first connection fetches all of it
alone. A number below 1 counts as 1, and one above 16 as 16.
The bindings report the whole file as before: received counts what every connection has
saved, and speed adds their speeds together. Pausing stops every connection and keeps
what each part has saved; resuming opens a new connection for each part that isn’t
finished, asking for the rest of it. Cancelling stops them all and removes the file.
Headers: headers: sends the same headers with every request the download makes,
each connection’s and each retry’s included, such as a token a server asks for:
download(url, to: folder, progress: $percent, status: $status, headers: ["Authorization: Bearer {token}"])
Each is written Name: value, and spaces around the name and the value don’t count. A
header that isn’t written that way, or holds a character a header can’t, such as a line
break, fails the download before it asks the server anything, with status saying which.
So does Range, which the download sets itself to fetch parts of the file.
Retries: retries: 3 tries a connection again, up to three times in a row, when it
fails in a way that may pass: the connection breaks off while the file arrives,
connecting takes longer than 30 seconds, or the server answers with an error of its own,
a status from 500 to 599, such as 503 Service Unavailable. The first retry waits a
second, and each after it twice as long as the one before, up to 30 seconds. A
connection that receives data again starts counting afresh. Any other failure, such as
404 Not Found, an address that doesn’t exist, or a full disk, fails the download at
once, as does a failure after the last retry.
While nothing is downloading because every unfinished part waits to retry, status says
Retrying…, and Downloading… again once a retry starts. Pausing or cancelling while
it waits works at once: a paused download doesn’t retry until it is resumed, and resuming
tries every unfinished part straight away. A retry continues where its part stopped,
asking for the rest with a range request, as resuming does.
Windows folders: \ starts an escape in a Nifty string, so write each backslash in a
path twice, as in to: "C:\\Users\\you\\Downloads", or use /, which Windows accepts
too: to: "C:/Users/you/Downloads". "~/Downloads" works on every system.
Ids, pausing and cancelling: bind id: to a string and the call writes the
download’s id there right away, such as download-3. Pass it to
pause_download, resume_download or cancel_download
to control the download. Ids are never reused while the app runs, even across nf dev
reloads.
state id = ""
button("Get") { download(url, to: folder, id: $id, progress: $percent, status: $status) }
button("Pause") { pause_download(id) }
button("Resume") { resume_download(id) }
button("Cancel") { cancel_download(id) }
Several at once: each call starts its own download, so you can keep each in an item
of a list. A download that reports into a list item needs id: in the same item:
downloads.push(Download(id: "", url: url, percent: 0, status: ""))
let last = downloads.len() - 1
download(url, to: folder, id: $downloads[last].id, progress: $downloads[last].percent, status: $downloads[last].status)
The bindings stay with the item that holds the id, wherever it moves: removing items
before it, or rebuilding the list with only some of them, doesn’t send its reports to
another item. Before each report, and after each action, the app looks for the item
holding the id and moves the download’s bindings in the same item there; a binding
somewhere else, such as a separate state, stays where it is. Removing the item, or
overwriting its id, cancels the download, as cancel_download does.
| macOS | Windows | |
|---|---|---|
| Cancelling | the partly saved file is removed at once | the partly saved file is removed at once, unless another program has it open; then status says Failed: cancelled; the partial file … is left behind |
With
nf dev: saving your code restarts the app’s program, which stops every download, running or paused, and removes its partly saved.partfile before the new version starts. Their state keeps its last value, so astatusstill saysDownloading…orPaused, and their ids no longer control anything; start them again to finish them.When the app quits: on Windows, downloads still running or paused stop and remove their
.partfiles as the app quits. On macOS the app ends without waiting, so a download still running or paused leaves its.partfile behind.
pause_download, resume_download, cancel_download
Control a running download by the id its download call wrote into id:.
for item in downloads {
button("Pause") { pause_download(item.id) }
button("Resume") { resume_download(item.id) }
button("Cancel") { cancel_download(item.id) }
}
| Function | Does | |
|---|---|---|
pause_download(id) | (first) the id, a string | stops downloading for now and keeps the partly saved file; status becomes Paused, received the bytes saved and speed 0 |
resume_download(id) | (first) the id, a string | carries on from where the paused download stopped; status goes back to Downloading… |
cancel_download(id) | (first) the id, a string | stops it for good and removes the partly saved file; status becomes Cancelled and speed 0 |
Each happens once the action has finished, in the order the code asked. An id that isn’t running, such as one that has just finished, does nothing, and so does pausing a paused download or resuming one that isn’t paused. Pausing takes effect at once, even while the server is slow to answer.
How resuming works: the download asks the server for the rest of the file, from the
first byte it doesn’t have, with an HTTP range request. A server that can’t send part of
a file sends all of it instead, and then the download starts over from zero: received
and progress go back to 0 and climb again, and the file still ends up whole. A paused
download whose file was already complete is saved as soon as it resumes.
| macOS | Windows | |
|---|---|---|
| A paused download’s file | report.pdf.part, in the folder it is saving to | the same |
| Cancelling | the partly saved file is removed at once | the partly saved file is removed at once, unless another program has it open; then status says Failed: cancelled; the partial file … is left behind |
format_bytes
Gives a number of bytes as text people read easily. It only reads, so labels and other UI can call it.
label(format_bytes(1500)) // 1.5 KB
label("{format_bytes(speed)}/s") // such as 2.4 MB/s
| Setting | Kind | |
|---|---|---|
| (first) bytes | int | required; the size, in bytes |
Under 1000 bytes it counts them, as 1 byte or 999 bytes. From there it uses the
largest of KB, MB, GB, TB, PB and EB that keeps the number at least 1, with one
decimal place, rounded to nearest: 1.5 KB, 15.2 MB. Each unit is 1000 of the one
before, as macOS and web browsers count, so a size reads the same on every system;
Windows Explorer counts in 1024s, so it can show the same file slightly smaller. A
negative number keeps its sign.
File dialogs: pick_file, pick_files, pick_folder, pick_save_path
Ask the user to choose a file or folder with the system’s own dialog, and write what they chose into state. The dialog opens once the button’s action has finished, so the rest of the action runs first and the window is up to date behind it.
state photo = ""
button("Choose photo…") { pick_file($photo, title: "Choose a photo", types: ["png", "jpg"]) }
label(photo)
| Function | Asks for | Binding |
|---|---|---|
pick_file | one existing file | a string |
pick_files | one or more existing files | a [string] |
pick_folder | one existing folder | a string |
pick_save_path | where to save a new file; it may already exist | a string |
| Setting | Kind | |
|---|---|---|
| (first) | binding | required; where the chosen path goes |
title | string | text shown in the dialog |
types | [string] | the file extensions allowed, such as ["png", "jpg"], with or without the dot; any file when left out. Not on pick_folder |
name | string | pick_save_path only: the file name it suggests |
from | string | the folder it opens in; ~ is your home folder |
What the binding gets: the full path, such as /Users/you/Pictures/cat.png, or
C:\Users\you\Pictures\cat.png on Windows. pick_files replaces the whole list. If
the user cancels, the binding keeps its value, so test it to see whether anything was
chosen. pick_save_path only chooses the path; it doesn’t create or write the file, and
when the user picks a file that exists, the system has already asked them to confirm
replacing it.
On each system:
| macOS | Windows | |
|---|---|---|
| Looks like | a sheet sliding down from the active window, or its own window when none is active | the system’s Open or Save As dialog, in front of the active window, which waits until it closes |
title | shown as a message at the top of the panel | the dialog’s title bar |
types | other files are greyed out; saving adds the first type’s extension | a filter listing the types; saving adds the first type’s extension |
from | ignored when the folder doesn’t exist | ignored when the folder doesn’t exist |
If the system can’t show the dialog, or an extension in types is one macOS doesn’t
know, the app reports an error pointing at the call, and the binding keeps its value.
With
nf dev: saving your code while a dialog is open restarts the program behind it; whatever the user then chooses is dropped.
A whole app that uses all four:
state photo = ""
state attachments: [string] = []
state folder = ""
state export_path = ""
view Choice(title: string, chosen: string) {
column(spacing: 2) {
label(title, style: .caption, color: .secondary)
if chosen.is_empty() {
label("Nothing chosen", color: .tertiary)
} else {
label(chosen, selectable: true)
}
}
}
window("Files", width: 520, height: 420) {
column(spacing: 14, padding: 20) {
label("Files", style: .title)
row(spacing: 12, align: .center) {
button("Choose photo…") { pick_file($photo, title: "Choose a photo", types: ["png", "jpg"]) }
Choice(title: "Photo", chosen: photo)
}
row(spacing: 12, align: .center) {
button("Attach files…") { pick_files($attachments, title: "Attach files") }
label("{attachments.len()} attached")
}
row(spacing: 12, align: .center) {
button("Choose folder…") { pick_folder($folder, from: "~") }
Choice(title: "Folder", chosen: folder)
}
row(spacing: 12, align: .center) {
button("Export…", style: .primary) { pick_save_path($export_path, name: "export.csv", types: ["csv"]) }
Choice(title: "Export to", chosen: export_path)
}
}
}
Windows: open_window, close_window
Open and close one of the app’s windows, which code names by the name written between
window and its title.
window settings("Settings", open: false) {
button("Done") { close_window(settings) }
}
menu("Downloader") {
menu_item("Settings…", shortcut: ",") { open_window(settings) }
}
| Function | ||
|---|---|---|
open_window(name) | (first) a window’s name | opens the window, building its content from the state as it is now; when it is open already, brings it to the front |
close_window(name) | (first) a window’s name | closes the window, if it’s open |
Both happen once the code that called them finishes, and the last call for a window decides: closing a window and opening it again in one action leaves it open. Windows open before others close, so an action can swap one window for another.
The app quits when its last window closes, whether the code or the user closes it, so
closing the only open window ends the app. A window that’s closed and opened again is a
new window: a text field’s selection or a scroll position starts over. With nf dev,
windows open when you save stay open, and closed ones stay closed.
| macOS | Windows | |
|---|---|---|
| New window | centered on the screen, in front, with the keyboard | centered on its monitor, in front, with the keyboard |
| Bringing an open window to the front | it comes to the front with the keyboard | it comes to the front; if another app has the foreground, Windows may only flash its taskbar button |
Where this comes from: the list of built-in functions is defined once in
crates/nifty-check/src/functions.rs. The checker and the editor’s autocomplete both read it, so they always agree.
The nf command
nf is the one tool you need for Nifty. Run nf help to see a summary.
A project is a folder with a main.nf file in it. Every .nf file in the folder
and its subfolders is part of the app, so you can split a bigger app across files; see
Projects. Anywhere a command takes a
[project], you can give a project folder, a single .nf file, or nothing at all to
use the current folder. A single file runs on its own, without the other files around
it.
nf create <name>
Makes a new folder called <name> with a starter app in main.nf.
nf create my-app
The name becomes the folder name and the window title. It must start with a letter and
use only letters, digits, - and _. nf create won’t overwrite a folder that
already exists.
nf run [project]
Checks the app, and if there are no errors, runs it with native windows.
nf run # the project in this folder
nf run my-app # a project folder
nf run demo.nf # a single file
The app runs until you quit it (⌘Q on macOS) or close its last window (Alt+F4 on Windows). If it hits an error while running, it stops and shows where.
nf dev [project]
Like nf run, but it reloads the app every time you save. Keep it running while
you work, and each change shows up in the open window within a moment.
nf dev my-app
my-app: started
watching my-app for changes; quit the app or press Ctrl-C to stop
my-app: reloaded
It watches every .nf file in the project folder and its subfolders, so saving any of
them reloads the app, and so does adding or deleting one. For a single .nf file, it
watches just that file.
- Your state is kept. If you’ve typed three todos and then change a label’s text,
the todos are still there after the reload. A
statekeeps its value as long as the new version still declares it with the same name and the same type. Rename it or change its type, and it starts again from its starting value. - Mistakes don’t close the app. If you save code with errors,
nf devprints them and keeps showing the last version that worked. Fix the code, save, and it reloads. If the very first version has errors, it waits for you to save a fix. - Errors while running don’t stop it either. If a button’s action hits an error,
nf devprints it and the app keeps running, so your next save can fix it. (nf runstops instead.) - Saving without changing anything doesn’t reload.
Stop it by quitting the app or pressing Ctrl+C in the terminal.
nf check [project]
Looks for mistakes without running anything, and lists every one it finds.
nf check
my-app: no problems found
Handy in scripts and CI, since nothing opens on screen.
nf lsp
Runs the language server, which gives editors errors-as-you-type, autocomplete, go to definition and more. Your editor starts this for you; you don’t run it by hand. See Set up your editor.
nf build [project] [--macos | --windows | --linux]
Checks the app, compiles it to machine code, and packages it as a native app you can give to other people.
nf build todos
built todos/build/todos.app
- The app goes in a
buildfolder next tomain.nf, named after the project folder (or after the file, for a single.nffile). Building again replaces it. - It builds for the system you’re on, which is also the default with no flag: on macOS,
a
.appbundle; on Windows, atodosfolder holdingtodos.exe. There’s no Linux backend yet, so--linuxdoesn’t work. - The app is one compiled program, linked with Nifty’s runtime library; the project’s
.nffiles aren’t inside it. Linking needs the system’s linker: Xcode’s command line tools on macOS, and Microsoft’s C++ Build Tools on Windows. - If the app has errors, they’re shown and nothing is built.
- An error while the app runs stops it, and is written to the terminal it was started
from as
file:line:column: message.
See Share your app for a walkthrough.
nf version
Prints the version of nf, such as nf 0.1.0-alpha.3. nf --version works too.
Exit codes
For scripts:
| Code | Meaning |
|---|---|
0 | everything worked |
1 | your program has errors (found by checking, or while running) |
2 | the command itself was wrong, e.g. a typo in the command or a missing folder |
The Nifty language — v0
Nifty is a small, statically typed language for native desktop apps. A .nf file
describes state, logic and UI; nf build turns it into a native binary that uses the
operating system’s own widgets (AppKit on macOS, Win32 on Windows, GTK on Linux).
v0 is the smallest language that can express a polished counter and todo app. Everything here is meant to stay; later versions add to it.
A complete app
struct Todo {
title: string
done: bool
}
state todos: [Todo] = []
state draft = ""
fn add_todo() {
if draft != "" {
todos.push(Todo(title: draft, done: false))
draft = ""
}
}
fn remaining() -> int {
let count = 0
for todo in todos {
if !todo.done {
count += 1
}
}
return count
}
view Header(left: int) {
row(align: .center) {
label("Todos", style: .title)
spacer()
label("{left} left", style: .caption)
}
}
window("Todos", width: 360, height: 480) {
column(spacing: 12, padding: 20) {
Header(left: remaining())
row(spacing: 8) {
text_field($draft, placeholder: "What needs doing?")
button("Add", style: .primary) { add_todo() }
}
divider()
for todo in todos {
checkbox(checked: $todo.done, title: todo.title)
}
}
}
Projects and the nf tool
A project is a folder with a main.nf in it. Every .nf file in the folder and its
subfolders is part of the app:
todo/
main.nf
ui/
header.nf
- One namespace. The files share their top-level names, so a
view,fn,structorstatedeclared in one file can be used in all of them. There is no include or import between files, and their order doesn’t matter, just as declarations within a file can come in any order. - A name declared twice is an error, even in two different files, and the error points at both declarations.
main.nfmarks the project and must exist. Windows can be declared in any file.- A subfolder with its own
main.nfis a separate project, not part of this one. That includes the appsnf buildwrites intobuild/. - A single
.nffile given to a command, such asnf run demo.nf, is an app on its own, even when it sits inside a project folder.
| Command | Does |
|---|---|
nf create <name> | creates <name>/main.nf from the starter app |
nf check [project] | reports syntax and type errors without running anything |
nf lsp | runs the language server for editors, over stdin and stdout |
nf run [project] | checks the app, then runs it with native windows |
nf dev [project] | runs the app and reloads it in place on every save; state keeps its value while its name and type stay the same |
nf build [project] [--macos | --windows | --linux] | checks the app, then packages it as a native app in build/ for the system you build on; Linux isn’t supported yet |
project is a project folder or a single .nf file, and defaults to the current
folder, so inside a project nf run and nf build need no arguments.
Lexical structure
- Source is UTF-8. Comments run from
//to the end of the line. - Newlines end statements. There are no semicolons. Inside
( )and[ ]newlines are ignored, and a line ending in a binary operator continues on the next line. - Identifiers:
[A-Za-z_][A-Za-z0-9_]*. By convention values and functions usesnake_case, and types and views usePascalCase. - Keywords:
state let fn view struct if else for in return true false. - Integers:
42,1_000. Floats:3.14. A float needs digits on both sides of the.. - Strings:
"..."with escapes\n \t \" \\ \{ \}.{expr}inside a string interpolates any expression:"{count} of {total}".
Types
| Type | Values |
|---|---|
int | 64-bit signed integer |
float | 64-bit float |
bool | true, false |
string | immutable UTF-8 text |
[T] | growable list of T |
Name | a declared struct |
Types are inferred from initial values; write them only where nothing can be inferred,
such as an empty list (state todos: [Todo] = []), parameters and return types.
There is no null. There are no implicit conversions; int and float do not mix.
Declarations
Top-level declarations may appear in any order.
state count = 0 // app state; the UI updates when it changes
struct Point { x: float, y: float } // or one field per line
fn area(w: float, h: float) -> float { return w * h }
view Badge(text: string) { label(text, style: .caption) }
window("App", width: 400, height: 300) { ... }
statedeclares app-wide mutable state. Every view that reads a state value updates automatically when it is assigned. The compiler computes which widgets depend on which state, so an update touches only those widgets: no diffing, no virtual DOM.fndeclares a function. Its return type defaults to “nothing”.viewdeclares a reusable piece of UI with parameters, used like an element. Its parameters are values: a view shows what it is given and is rebuilt when that changes.structdeclares a record type, constructed with named arguments:Todo(title: "x", done: false).window(...)declares a window. An app has one or more.window settings(...)names one, foropen_window(settings)andclose_window(settings).menu(...)declares a menu of the app’s menu bar; see Menus.
Statements
let total = a + b // a local variable
count = 0 // assignment to state, a field or a list element
count += 1 // also -=, *=, /=
if x > 0 { ... } else if x < 0 { ... } else { ... }
for item in items { ... }
for index, item in items { ... } // index counts from 0
for i in 0..n { ... } // 0, 1, …, n - 1
return value
do_something() // a call as a statement
for index, item in itemsalso names each item’s position, anintcounting from 0.for i in start..endcountsifromstartup to, but not including,end; both areints, worked out once before the loop starts. Whenendisn’t abovestart, the body doesn’t run...only appears here: a range is a loop form, not a value.- The loop sets the index and the range’s counter each time round, so they can’t be assigned. The item of a list loop can, as described under expressions.
Expressions
From loosest to tightest binding:
| Operators | |
|---|---|
|| | logical or |
&& | logical and |
== != < <= > >= | comparison (non-associative) |
+ - | add, subtract; + also joins strings |
* / % | multiply, divide, remainder |
! - $ | not, negate, two-way binding |
f(x) a.b a[i] | call, field or method, index |
- Calls take positional arguments first, then named ones:
button("Save", style: .primary). - A call may be followed by a trailing block on the same line:
button("+") { count += 1 }. For layout elements the block holds children; for controls it is the action. .nameis an implicit member: the value comes from the expected type, soalign: .centermeans thecenteralignment.$placecreates a two-way binding for controls that edit a value, and for built-in functions that report into state:text_field($draft),checkbox(checked: $todo.done),download(url, to: folder, progress: $percent, status: $status). The place must be state, or aforvariable looping over state, optionally followed by fields and list items.- Values are copied on assignment: after
b = a, changingbleavesaalone. Aforvariable looping over a place is the exception: it stands for each element, sofor todo in todos { todo.done = true }changestodos. - Lists:
[1, 2, 3],items[0],items.len(),items.push(x),items.remove(i). - Built-in functions:
download(url, to:, progress:, status:)saves a file from the web on a background thread,pause_download(id),resume_download(id)andcancel_download(id)control one,format_bytes(n)shows a size such as1.5 MB, andpick_file,pick_files,pick_folderandpick_save_pathask the user for a path with the system’s file dialog, andopen_windowandclose_windowopen and close a named window; see Built-in functions. Their names are reserved like element names.
UI
A UI block is a sequence of elements. if and for work inside it to show elements
conditionally, once per list item, or once per count of a range. Element results are not assigned to variables; a
view is a description, and the compiler builds and updates the native widgets.
Elements in v0
| Element | Arguments | Trailing block |
|---|---|---|
window name(title, width:, height:, backdrop:, open:) | name optional, for code to open and close it by; size defaults to 480 × 320; backdrop: .standard (default), .translucent, .solid; open: false waits for code to open it | children |
column(spacing:, padding:, align:, justify:, surface:, width:, max_width:, grow:) | all optional | children |
row(spacing:, padding:, align:, justify:, surface:, width:, max_width:, grow:) | all optional | children |
scroll(spacing:, padding:, align:, justify:, surface:, width:, max_width:) | a column that takes the space left and scrolls its children; see scroll | children |
label(text, style:, color:, align:, selectable:) | style: .body (default), .large_title, .title, .headline, .caption; see label | — |
button(title, style:, icon:, selected:) | style: .normal (default), .primary, .plain, .icon; selected, a bool that stays up to date; see button | action |
text_field($value, placeholder:, lines:) | lines, a whole number from 1 (default) to 100, makes it that many lines tall | action on Return, one-line fields only; optional |
checkbox(checked: $value, title:) | title optional | — |
switch(on: $value, title:) | title optional; see switch | — |
slider($value, min:, max:) | a bound int; min and max written as whole numbers, 0 and 100 by default | — |
picker($value, options:) | a bound string, and a [string] of options; see picker | — |
nav_item(title, value:, selection: $value, icon:, badge:) | see nav_item | — |
image(path, width:, height:) | path, a full path or one starting with ~; width and height written as numbers, keeping the image’s shape when only one is given; see image | — |
progress(value:) | value, from 0 to 100, optional; without it the bar shows that work is under way | — |
spacer() | takes the remaining space | — |
menu(title) | at the top level, a menu of the menu bar; in a menu, a submenu; see menu | entries |
menu_item(title, shortcut:) | in a menu; shortcut such as "n" or "shift+n", with Cmd on macOS and Ctrl on Windows | action |
context_menu() | among a stack’s children; shown when the stack is right-clicked; see context_menu | entries |
drop_target($dropped) | among a stack’s children; a bound [string] that a drop of files and links fills with their paths and addresses; see drop_target | action after a drop; optional |
divider() | — |
Text, titles and bound values can come from any expression and stay up to date. Layout
numbers, styles, flags, the window title and placeholders are written literally, as in
spacing: 12, style: .title, selectable: true and window("Todos").
align (cross axis): .stretch (default), .start, .center, .end.
justify (main axis): .start (default), .center, .end, .space_between.
surface: .none (default), .sidebar, .content, .card; see column.
Text styles map to each platform’s system text styles, so apps follow the system font and the user’s text size (Dynamic Type on macOS, Accessibility text size on Windows) without extra work. Apps follow dark mode on both platforms, and high contrast on Windows.
How the UI stays up to date
The compiler records which state each dynamic part of the UI reads, including state read
inside functions it calls. When an action changes state, only the parts that read it are
updated: a label gets its new text, and an if, for or view whose inputs changed is
rebuilt in place. Showing the UI may read state but never change it; changes happen in
button actions and through bindings.
Not in v0
Per-view local state, view parameters that are bindings, keyed for loops that keep
unchanged rows, enums and match, closures as values,
async work, networking, files, custom drawing. Each is planned; none is needed for the
first apps. The syntax of the language features is already settled in
Syntax decisions, rows 5–11, and
What Nifty supports tracks where each one stands.
Syntax decisions
Rows 1–4 were settled by comparing this spec with an alternative sketch, and the draft’s form was kept in each case. Rows 5–11 settle the syntax of planned features before they are built, row 12 records a form added since, rows 13–23 were settled as the UI elements they describe were built, and rows from 24 on as downloads gained control. The reasoning is recorded so it isn’t relitigated.
| # | Question | Chosen | Alternative | Why |
|---|---|---|---|---|
| 1 | Parentheses on elements | label("Hi") | label "Hi" | Keep parentheses as the one form. A paren-less shorthand reads well for one string, but every element with options needs parentheses anyway, which gives two ways to write the same thing. The same holds for views and empty argument lists: view Header() { … }, row() { … }. |
| 2 | Where element settings go | Arguments: window("Counter", width: 300, height: 200) { … } | Lines in the block: title: "Counter", size: { width: 300 height: 200 } | Arguments. The block then holds only children, and Nifty needs no object-literal syntax. |
| 3 | Enum values | align: .center | align: center | .center for now. A bare center can collide with a variable named center. Once the type checker exists, bare names could work when nothing else has that name. |
| 4 | Named windows | window("Counter"), and window main("Counter") { … } for one code opens or closes | window main { … } | Names came with opening and closing windows from code: open_window(main). A window without a name is still window("Counter"), since only windows code refers to need one. |
| 5 | Apps bigger than one file | Every .nf file in the project folder is part of the app | $include "ui/header.nf", or import between your own files | Top-level declarations already work in any order, so splitting them across files changes nothing. A project is one namespace, and a name declared twice is an error that points at both. $ already means a binding, and a textual include can paste a file twice or depend on order. import is kept for packages from outside the project. |
| 6 | View parameters that edit a value | view Stepper(value: $int) { … }, used as Stepper(value: $count) | inout int, binding int | $ in the type matches $ at the call site. Inside the view, value is read and assigned like any value, and $value passes the binding on. |
| 7 | State that belongs to one view | state at the start of a view’s body | A separate local keyword | The same word for the same idea. Each view instance has its own copy, identified by its place in the UI and, inside a keyed for, by its key. |
| 8 | Keyed for | for todo in todos by todo.id { … } | for todo in todos, key: todo.id, or keying any struct with an id field automatically | One short keyword, and keys are always written down. A for without by keeps rebuilding rows as it does today. |
| 9 | Enums | enum Load { idle, loading(percent: int), failed(message: string) }, values written .idle or Load.idle | PascalCase cases | snake_case cases match the built-in .center and .space_between. Built-in options become ordinary enums, so .name works the same way for both. |
| 10 | match | .loading(percent) => label("{percent}%"), or => { … } for several lines | .loading(percent) { … }, like if | => reads well when match is an expression (let text = match load { … }) as well as a statement or a UI block. A match must cover every case. |
| 11 | Functions as values | Type fn(int) -> bool, value { todo in !todo.done } | fn(todo) { … }, |todo| … | A trailing block is already a function with no arguments; naming its arguments before in extends the same form. The main use is views that take callbacks, such as view Row(on_delete: fn()). |
| 12 | Loop positions and counting | for index, item in items { … }, and for i in 0..n { … } counting from 0 up to but not including n | items.enumerate() and range(0, n); Swift’s 0..<n; an inclusive 0...n | Both are loop forms rather than values, so there’s no tuple or range type to learn and nothing to allocate. The index comes first, as in Go’s for i, x := range xs, which keeps the item next to in. .. leaves out its end, as in Rust, which fits positions: 0..items.len() is every position. An inclusive form can come later as ..=. |
| 13 | A switch’s binding | switch(on: $wifi, title: "Wi-Fi") | checkbox(checked: $wifi, style: .switch) | A switch is its own control on both platforms, NSSwitch and Fluent’s toggle switch, laid out differently from a checkbox, so it is its own element. on: says what the switch’s state means, where checked: describes a tick. |
| 14 | A slider’s range | slider($volume, min: 0, max: 10), whole numbers written literally | A range expression, or float values | Ranges are layout, written literally like sizes, and the same on both platforms. Whole numbers cover counts, limits and percentages; a float slider can come later as its own setting without changing this one. |
| 15 | A picker’s options | picker($speed, options: speeds), any [string] expression | Options written as a block of elements | Options are data that can come from state and change as the app runs, so they are an expression, and the binding holds the picked option itself rather than its position. |
| 16 | Picking one of a few choices | A selected state for buttons: button("All", selected: filter == "all") { filter = "all" } | A segmented control: segments($filter, options: ["All", "Done"]) | Both platforms have toggle buttons: macOS’s on-off buttons, and Fluent’s toggle buttons, which Win32 builds as push-like checkboxes that screen readers announce as pressed. Win32 has no segmented control, so one would be a single control Nifty draws whose segments screen readers can’t reach. Buttons also keep their icons, titles and styles, and work in a for. |
| 17 | Several lines of text | text_field($notes, lines: 4) | A separate text_area($notes) element | It edits the same bound text the same way, so it is the same element; how many lines tall it is says how big it is, as width does for a stack, and one line stays the default. |
| 18 | Where an image comes from | image(path, width:, height:), a full path, or one from ~ | image(named: "logo") for images that come with the app | A path is text, so an image can show a file the app just downloaded or the user picked. Images that come with an app need nf build to bundle them and a rule for finding them next to the code, which is left for later, so a relative path written directly is an error rather than a guess. |
| 19 | A text field’s action | A trailing block: text_field($query) { search() }, run on Return | on_submit: { … }, or action: | A trailing block is already how a control takes its action, so a text field takes one the same way; leaving it out keeps Return for the window’s primary button. |
| 20 | Menus and shortcuts | Top-level menu("File") { menu_item("New", shortcut: "n") { … } }, with divider() and nested menus | Menus as a setting of window, or shortcuts written "cmd+n" | Menus are the app’s, not one window’s, so they sit at the top level like windows, and their items take an action the way buttons do. A shortcut always uses the platform’s command key, so one shortcut means Cmd+N on macOS and Ctrl+N on Windows; writing cmd or ctrl would make code platform-specific. |
| 21 | Context menus | A context_menu() { … } child of the stack it belongs to, holding the same menu_items, divider()s and submenus as a menu | A context_menu: setting on each element, or a block on menu with a context: true flag | A child can be added inside any stack, a view’s or a for row’s, without every element growing a setting, and it reuses the items menus already have; its items run with the names around them, so a row’s menu acts on its row. Context menu items take no shortcut: a shortcut can’t say which row it means. |
| 22 | Opening and closing windows | open_window(settings) and close_window(settings), with open: false on a window that waits for code | Methods on the name, settings.open(), or a bool state bound to each window | Built-in functions are how code asks the system for things, as pick_file does, and a window’s name is not a value with methods. A bound bool would have to say which window is open when the user closes one, which a request doesn’t. |
| 23 | Dropping files and links | A drop_target($dropped) { … } child of the stack it belongs to, writing a [string] of paths and addresses, then running its block | A drop: setting on stacks, or a callback that receives the list as a parameter | It covers its stack the way context_menu does, and reports into state through a binding, as pick_files($paths) does, so code reads what was dropped like any state and the block only decides what to do with it. Files and links share one list of strings, since a link and a path are both where something is. |
| 24 | Referring to a running download | download(url, to:, id: $items[last].id, …) writes a string id into the item, and pause_download(item.id), resume_download(item.id) and cancel_download(item.id) take it; a download reporting into a list item must bind id: there, and its other bindings in that item follow the item that holds the id | download returning a handle value, or an int id, or bindings that track list positions as items are removed | An id is ordinary data the app already stores per row, so a row’s buttons reach their download as item.id, and finding the row that holds it survives every way a list changes: removing items, rebuilding the list with only some of them, or sorting it. Nifty has no handle type or null, and tracking positions would break whenever code rebuilds a list. A string, such as download-3, can’t be mistaken for a count or a position, and is unique for the whole run, nf dev reloads included. A function per request matches open_window: code asks the system for something by name. |
| 25 | Downloading over several connections | download(url, to:, connections: 4, …), an int setting that defaults to 1 | A parallel: true flag with a fixed count, or the runtime choosing a count on its own | How many connections a server tolerates differs from server to server, so the app says, and a number can come from state, such as a settings slider. One connection stays the default, since that is what a server always expects. Parts are a detail of how the file arrives: the bindings, pausing and cancelling still describe the whole download, so nothing else in the call changes. |
| 26 | Choosing a download’s file name | download(url, to:, name: "report.pdf", …), a string setting where "", the default, names the file after the address | A full path in to:, or a save_as: setting | The folder and the name are separate choices: the folder is usually a setting, the name comes from the item. A name rather than a path means it is made safe the same way as one from the address and can’t leave the folder, and it keeps the rule that a download never replaces a file, numbering it report (1).pdf instead. name matches pick_save_path’s setting for the same thing. An empty string rather than an optional value, since Nifty has no null. |
| 27 | Request headers | download(url, to:, headers: ["Authorization: Bearer {token}"], …), a [string] with each header written Name: value | A map or record type, such as headers: {"Authorization": token}, or a header per setting | Nifty has no map type, and a list of strings is already a setting kind, as types: on the file dialogs. Writing a header as it appears on the wire needs nothing new to learn, and interpolation fills in values. A header that can’t be sent fails the download with a clear status rather than the checker, since the text can come from state. |
| 28 | Retrying failed connections | download(url, to:, retries: 3, …), an int count that defaults to 0, with a fixed backoff of 1 second doubling to at most 30 | Settings for the delays, or retrying by default | A count is the one choice that differs between apps; the delays follow common practice and stay the same everywhere, so a setting for them would only add knobs. Only failures that may pass are retried, so a 404 still fails at once. No retries by default keeps a failing download’s status honest unless the app asks to keep trying, and Retrying… says when it does. |
How Nifty works
This page follows a .nf file from text on disk to a window on screen. You don’t need
any of this to use Nifty, but it’s the map to read before changing it.
The big picture
main.nf and the project's other .nf files
│
▼
┌────────────────────────────────┐
│ nifty-syntax │ text → tokens → syntax tree
│ lexer, parser │ (+ syntax errors)
└────────────────────────────────┘
│ ast::File
▼
┌────────────────────────────────┐
│ nifty-check │ names, types, element arguments,
│ checker │ and which state each UI part reads
└────────────────────────────────┘
│ hir::Program
┌─────────┴──────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ nifty-interp │ │ nifty-lsp │ editor features
│ runs it │ │ (nf lsp) │ (diagnostics, completion, …)
└──────────────┘ └──────────────┘
│ creates and updates widgets
▼
┌────────────────────────────────┐
│ nifty-runtime │ widget tree + flexbox layout,
│ Ui, Backend trait, layout │ same on every OS
└────────────────────────────────┘
│ Backend trait
▼
┌────────────────────────────────┐
│ nifty-platform │ picks the backend for this OS
└────────────────────────────────┘
│
┌────┴───────────────┐
▼ ▼
┌──────────────┐ ┌───────────────┐
│ nifty-macos │ │ nifty-windows │
│ AppKit │ │ Win32 │
└──────────────┘ └───────────────┘
nifty-cli (the nf program) ties these together: it reads the project, calls the
syntax and check stages, prints errors, and starts the interpreter or the language
server. It also packages apps (nf build) and watches files for live reload
(nf dev); both are described below.
Stage 1: Syntax (nifty-syntax)
Input: source text. Output: a syntax tree (ast::File) plus a list of
diagnostics.
- The lexer cuts the text into tokens: names, numbers, strings, operators,
keywords and line breaks. Line breaks are tokens because they end statements; the
lexer drops the ones inside
( )and[ ]or after a binary operator. - The parser builds the tree from the tokens. It never gives up: when something doesn’t parse, it records an error, skips ahead, and keeps going, so you see every mistake at once, and the editor still understands the rest of the file.
Stage 2: Checking (nifty-check)
Input: the syntax tree. Output: a checked program (hir::Program) plus
diagnostics.
The checker:
- resolves names, so every use of
countpoints at the onestate count; - works out types and makes sure they fit, such as not adding
inttofloat; - checks elements: every
label,buttonand the rest gets the arguments it accepts, using the single table inelements.rs; - enforces the rules that keep apps predictable, such as “showing the UI can’t change state”;
- records dependencies: for each dynamic part of the UI (a label’s text, an
if, afor, a view), it records which state that part reads, including state read inside functions it calls.
That last step is what makes Nifty’s updates precise. It’s all worked out before the app runs.
Stage 3: Running (nifty-interp)
Input: the checked program and a platform backend.
The interpreter:
- sets every
stateto its starting value; - mounts each window: walks the UI description and asks the runtime to create a native widget for each element;
- waits for events. When you click a button, it runs the action, and notes which state changed;
- updates only the UI parts whose recorded dependencies changed: new text for a
label, or rebuilding an
if,foror view region in place.
Live reload (nf dev)
nf dev runs the app as a LiveApp, whose program can be swapped while it runs. A file
watcher wakes the UI thread when a .nf file in the project folder is saved; nf dev
then re-reads and re-checks the source. If it has errors, they’re printed and the old
version keeps running. If not, the new program replaces the old one: each state the new
program declares with the same name and an equivalent type keeps its value (see
machine/carry.rs), the rest start from their starting values, and the windows are
rebuilt from those values.
Built apps (nf build)
nf build checks the project, then compiles the checked program to machine code with
nifty-codegen, which uses Cranelift. It compiles every
function, every state’s starting value, and every text, binding, action and region of
the UI, and emits a descriptor of the windows and views as data. It links that object
file with nifty-rt, a static library installed next to nf, using the system’s linker
(cc on macOS, the MSVC linker on Windows), and writes the executable into a macOS
.app bundle or a Windows folder in build/.
When the app starts, nifty-rt reads the descriptor and shows the windows through the
same engine the interpreter uses, calling the compiled functions where the interpreter
would evaluate checked code. Compiled code calls back into nifty-rt for anything
beyond arithmetic: strings, lists and structs, which are reference-counted and copied
when changed, state, downloads and errors. The tests in nifty-codegen run programs
compiled and interpreted side by side and check they behave the same.
nf has only its own system’s runtime library and linker, so it builds apps for the
system it runs on.
Stage 4: Widgets and layout (nifty-runtime, nifty-macos, nifty-windows)
nifty-runtime is the part that’s the same on every operating system. It keeps a tree
of widgets, lays them out with flexbox (using the Taffy
library), and talks to the OS through one trait, Backend.
Each OS gets its own crate that implements Backend with that system’s real widgets:
nifty-macos (AppKit) and nifty-windows (Win32 common controls). nifty-platform
picks the right one at compile time. Because the trait lists every widget operation, a
new element can’t compile until both backends implement it. New backends, such as Linux
(GTK), slot in next to them without touching anything above.
The Backend trait also has a waker: a way for another thread, such as nf dev’s
file watcher, to ask the UI thread to run something. Native toolkits only allow UI work
on their main thread, so this is how outside events get in.
File dialogs go through the waker too. Code that calls pick_file only asks for one;
once the event has finished, the waker’s handler shows it with the backend’s
show_file_dialog, outside any event, because Windows runs a modal loop there that
handles other events. The answer is queued and the waker runs again to write it into
state, the same way on both systems.
Editor support (nifty-lsp)
The language server finds the project each open file belongs to, the nearest folder
above it with a main.nf, and analyses all of the project’s files together, using the
text of open files and the saved text of the rest. It reuses stages 1 and 2 exactly as
nf check does, then answers
editor questions from the result: errors, completions, signature help, hover, semantic
colors, the outline, go to definition and references. Because it uses the same checker, the editor and
nf check can never disagree.
Where to read next
- Browse the source code: every crate and file, with links.
- Language specification: the exact rules the checker enforces.
Browse the source code
All of Nifty is open source on GitHub: github.com/istorry/nifty.
Each part below links to two places:
- Source opens the code on GitHub.
- API docs opens the generated Rust documentation for that crate: every type and
function, with its doc comments, cross-linked, and a
sourcelink on each item.
Not sure where to start? Read How Nifty works first; it explains how these pieces fit together.
The repository at a glance
nifty/
├── crates/ the Rust code that makes up Nifty
│ ├── nifty-syntax/ text → syntax tree
│ ├── nifty-check/ syntax tree → checked program
│ ├── nifty-interp/ runs a checked program
│ ├── nifty-codegen/ compiles a program to machine code and links it
│ ├── nifty-rt/ the runtime library compiled apps link against
│ ├── nifty-runtime/ widgets, layout and the engine apps run on, on any OS
│ ├── nifty-download/ `download`, for interpreted and compiled apps
│ ├── nifty-platform/ picks the backend for this OS
│ ├── nifty-macos/ the macOS (AppKit) backend
│ ├── nifty-windows/ the Windows (Win32) backend
│ ├── nifty-lsp/ the language server for editors
│ └── nifty-cli/ the `nf` command
├── editors/ editor extensions and grammars
│ ├── vscode/
│ ├── zed/
│ └── tree-sitter-nifty/
├── fixtures/ .nf programs the tests read
└── docs/ this book
Crates
nifty-syntax: reading source code
Turns text into a syntax tree, with error recovery. Source · API docs
| File | What’s in it |
|---|---|
lexer.rs | splits text into tokens; decides which line breaks matter |
token.rs | the token kinds and the keyword list |
parser/ | the parser, split into items, statements and expressions |
ast.rs | the syntax tree types |
diagnostic.rs, span.rs | error messages and source positions, each in one file of the project |
project.rs | which .nf files make up a project |
nifty-check: understanding the program
Resolves names, checks types, and records what state each part of the UI reads. Source · API docs
| File | What’s in it |
|---|---|
elements.rs | the one table of built-in UI elements and their arguments |
functions.rs | the one table of built-in functions, such as download |
methods.rs | the one table of built-in list and string methods |
hir.rs | the checked program that the interpreter runs |
checker/ | the checker: declarations, statements, expressions, UI, arguments, constants |
tests/grammars.rs | checks the editor grammars, docs highlighter and reference lists against those tables |
nifty-interp: running the program
Runs a checked program on the runtime’s engine, evaluating every dynamic part of the UI by walking the checked code. Source · API docs
| File | What’s in it |
|---|---|
app.rs | starts and reloads a program on the engine |
lower.rs | turns checked windows and views into the engine’s templates |
logic.rs | the interpreter as the engine’s Logic: text, bindings, actions and regions |
live.rs | an app whose program can be swapped while it runs, for nf dev |
machine/carry.rs | which state values survive a reload |
machine/ | evaluates statements and expressions; reads and writes places |
value.rs | runtime values |
nifty-codegen: compiling to machine code
Compiles programs to object files with Cranelift, and links them against nifty-rt with
the system’s linker: cc from Xcode’s command line tools on macOS, and the MSVC linker
on Windows.
Source ·
API docs
| File | What’s in it |
|---|---|
compile/ | compiles a checked program and emits the descriptor the runtime reads |
compile/body.rs | statements, expressions and places, with the reference counting that keeps copies independent |
compile/ui.rs | windows, views, templates, and the functions behind their dynamic parts |
emit.rs | writes functions and data into an object file, or into memory for tests |
object.rs | an app’s object file, with the main that runs it |
link.rs | links an object and the runtime library into an executable |
tests/compiled.rs | runs programs compiled and interpreted side by side, and checks they match |
tests/link.rs | compiles, links and runs a program for real; build the workspace first |
nifty-rt: the runtime library compiled apps link against
A static library holding everything a compiled app needs at run time. Compiled code
calls its nf_* functions and hands nifty_rt_main a descriptor of its states, windows
and views, which it shows through the same engine the interpreter uses.
Source ·
API docs
| File | What’s in it |
|---|---|
program.rs | the descriptor compiled programs emit, whose layout is the contract with nifty-codegen |
value.rs | values in 64-bit slots; reference-counted strings, lists and structs |
abi/ | the nf_* calls, and who owns the values passed to them |
context.rs | state, call depth, and why code failed |
logic.rs, lower.rs, show.rs | compiled code as the engine’s Logic |
place.rs | places in state that later writes go to, as compiled code names them |
download.rs, dialog.rs | download and the file dialogs in compiled apps |
symbols.rs | the names compiled code links to |
nifty-download: downloads
Runs each download on its own thread and hands its reports back to the UI thread, for
both the interpreter and compiled apps.
Source ·
API docs
nifty-runtime: widgets, layout and the engine
The OS-independent widget tree and flexbox layout, the Backend trait each OS
implements, and the engine that keeps an app’s windows in step with its state.
Source ·
API docs
| File | What’s in it |
|---|---|
reactive/ | the engine: mounts templates, handles events, updates only what changed, and the Logic trait that gives templates their values |
state.rs, failure.rs | which state UI reads; why running code fails, the same however it runs |
ui.rs | the retained UI: create, arrange and update widgets |
backend.rs | the Backend trait a platform implements, and the Waker other threads use to reach the UI thread |
dialog.rs, paths.rs | file dialogs: what an app asks for, and the queue that shows them between events and hands back the answers; ~ in paths |
layout.rs | flexbox layout with Taffy |
style.rs, geometry.rs | text/button/stack styles; points, sizes, frames |
testing.rs | an in-memory fake backend for tests |
nifty-macos, nifty-windows and nifty-platform: real native widgets
nifty-macos implements Backend with AppKit, and nifty-windows implements it with
Win32 common controls. Each backend crate compiles to nothing on other systems, so the
workspace builds on both macOS and Windows; CI runs every test on each. nifty-platform picks the backend for the
OS you build on. Windows executables need the application manifest that nifty-cli’s
build.rs embeds, which turns on Common Controls 6 and per-monitor DPI awareness.
macOS source ·
macOS API docs ·
Windows source ·
platform source ·
platform API docs
nifty-lsp: editor support
The language server behind nf lsp.
Source ·
API docs
| File | What’s in it |
|---|---|
server.rs | the request loop |
workspace.rs | open files and the projects they belong to |
project.rs | a project’s files, analysed together |
document.rs | one file’s text, syntax tree and position conversion |
index/ | an index of every declaration and use, for navigation |
features/ | diagnostics, completion, semantic tokens, outline, navigation |
nifty-cli: the nf command
nf, the tool you use.
Source ·
API docs
| File | What’s in it |
|---|---|
lib.rs | the usage text; parses the command line and dispatches |
main.rs | the entry point |
create.rs, run.rs, dev.rs, check.rs, build.rs, lsp.rs | one file per command |
build/macos.rs | writes the .app bundle around the compiled executable |
build/windows.rs | writes the Windows app folder around the compiled executable |
frontend.rs | reads a project, checks it, and says where spans are; shared by every command |
report.rs | prints errors with the source line underneath |
templates/main.nf | the starter app nf create writes |
Editors
| Folder | What’s in it |
|---|---|
editors/vscode | VS Code extension: TextMate grammar + language client |
editors/zed | Zed extension: Tree-sitter queries + language server setup |
editors/tree-sitter-nifty | the Tree-sitter grammar |
This book
The docs you’re reading are in
docs/, written in Markdown and built
with mdBook. Every page has an edit button at
the top right.
Contributing
Thanks for wanting to help! Bug reports, confusing error messages, docs fixes and code are all welcome.
Reporting a problem
Open an issue with:
- the smallest
.nffile that shows the problem, - what you expected, and what happened instead (paste the full output of
nf checkornf run).
Building from source
You’ll need Rust 1.87 or newer, on macOS or Windows. On Windows, use the MSVC toolchain (the default from rustup).
git clone https://github.com/istorry/nifty.git
cd nifty
cargo build
To put the nf you built on your PATH in place of a packaged one, install it, then
copy the runtime library apps are linked against next to it:
cargo install --path crates/nifty-cli
cargo build --release -p nifty-rt
cp target/release/libnifty_rt.a ~/.cargo/bin/
On Windows, the library is target\release\nifty_rt.lib. On macOS, make install does
all three.
Run the nf you just built without installing it:
cargo run --bin nf -- run fixtures/todos.nf
Before you send a change
A feature is done only when every part of Nifty has it: the checker, the interpreter,
both the macOS and Windows backends, the language server, the editor grammars and the
docs. CLAUDE.md has the full
checklist, says which parts tests already keep in step, and lists the commands to run.
These must pass, and CI runs them on macOS and Windows for every pull request:
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo build --workspace
cargo test --workspace
The build comes first because the link test links against the runtime library it leaves
in target/debug.
The workspace turns on Clippy’s pedantic lints and forbids unwrap(), so expect it to
be strict.
Where things go
| You want to… | Start in |
|---|---|
| change how code is read, or add syntax | crates/nifty-syntax (then the grammars) |
| add or change a type rule or error message | crates/nifty-check/src/checker |
| add a UI element | crates/nifty-check/src/elements.rs, then the runtime and both backends |
| add a built-in function or method | crates/nifty-check/src/functions.rs or methods.rs, then crates/nifty-interp |
| change how apps run | crates/nifty-interp |
| change a native widget | crates/nifty-macos and crates/nifty-windows |
| add an editor feature | crates/nifty-lsp/src/features |
See How Nifty works for how these fit together.
Keep the grammars in step
Syntax lives in the real parser (crates/nifty-syntax) and in four highlighters: the
Tree-sitter grammar (editors/tree-sitter-nifty, which Zed’s queries in
editors/zed/languages use), the TextMate grammar (editors/vscode/syntaxes) and the
docs highlighter (docs/theme/nifty-highlight.js). cargo test fails when one of them
is missing a keyword, type, element, function or method, and names it. Add a test case
for new syntax to each grammar and run their tests:
cd editors/tree-sitter-nifty && npm run generate && npm test
cd editors/vscode && npm test
Working on these docs
The docs are an mdBook in docs/. To preview
them while you edit:
cargo install mdbook --version 0.5.4 --locked
mdbook serve docs --open
The page reloads as you save. A few conventions:
- Write for someone new to programming. Short sentences, one idea at a time, and an example for every rule.
- Mark Nifty code blocks with
nfso they get syntax colors. - Show a whole app with
{{#include}}fromfixtures/rather than copying it, so the docs can’t drift from code the tests check.
Pushing to main publishes the docs to GitHub Pages automatically, together with the
API docs from cargo doc.
Releasing
Releases are built by release.yml.
Bump version in the root Cargo.toml, merge it, then push a matching tag:
git tag v0.2.0
git push origin v0.2.0
A version with a pre-release part, such as 0.1.0-alpha.1 (tag v0.1.0-alpha.1), is
published as a GitHub pre-release.
The workflow builds nf and the runtime library it links apps against for macOS (one
universal binary) and Windows (x64 and Arm64), attaches them to a GitHub release, then:
- commits a new
Formula/nifty-cli.rbto theistorry/homebrew-taprepository, made bypackaging/homebrew/formula.sh; - opens a pull request on
microsoft/winget-pkgswith the manifests frompackaging/winget/manifests.ps1. A winget moderator merges it, usually within a few days.
The winget step is its own workflow, .github/workflows/winget.yml. If it fails, fix the
cause on main and resubmit the existing release without tagging again:
gh workflow run winget.yml -f version=0.1.0-alpha.1
It needs two repository secrets:
| Secret | What it is |
|---|---|
HOMEBREW_TAP_TOKEN | a fine-grained token with Contents: read and write on istorry/homebrew-tap |
WINGET_TOKEN | a classic token with the public_repo scope; winget pull requests come from its account’s fork of winget-pkgs |
What Nifty supports
Nifty is at version 0: the smallest language that can build a polished counter and todo app. Everything in it is meant to stay; later versions add to it. This page says what works today, what’s being built now, and what isn’t there yet.
Platforms
| macOS | Windows | Linux | |
|---|---|---|---|
Run apps: nf run, nf dev | Yes, with AppKit | Yes, with Win32 | Not yet |
Share apps: nf build | A .app; needs Xcode’s command line tools | A folder with an .exe; needs Microsoft’s C++ Build Tools | Not yet |
Install nf | Homebrew | winget | Not yet |
| Window backdrops | A translucent window | Mica or Acrylic in the frame, on Windows 11 22H2 and later | Not yet |
| Icons | SF Symbols | Segoe Fluent Icons on Windows 11, Segoe MDL2 Assets on Windows 10 | Not yet |
Where the two platforms draw a setting differently, its entry in UI elements or Built-in functions says how.
Supported
- The full v0 language: state, functions, structs, lists, views and windows.
- Loops that name each item’s position,
for index, item in items, and loops that count,for i in 0..n. - Apps split across files: every
.nffile in the project folder is part of the app, in the CLI,nf devreloads and the editor. - Built-in UI elements, with flexbox layout, and label colors, alignment and selectable text.
- Surfaces: sidebars, content panes and cards drawn with each platform’s own materials, fixed and limited widths, and windows that stay wide enough for their content.
- Icons from the system’s own sets, borderless and icon-only buttons, a large title
style, and selectable sidebar rows (
nav_item). - Progress bars, filled to a percentage or showing that work is under way.
- Scrolling areas, which scroll their content up and down in the space they’re given.
- Switches, sliders for whole numbers, and pickers that choose a string from a list.
- A selected state for buttons, for picking one of a few choices.
- Text fields several lines tall, which wrap and scroll.
- Images from files, at their own size or a fixed width or height.
- A text field’s own action, run on Return.
- Menus and keyboard shortcuts: the menu bar on macOS, and every window’s menu bar on Windows.
- Context menus, shown when the user right-clicks a stack.
- Opening and closing windows from code, with windows that start closed.
- Dropping files and links onto a stack, which writes their paths and addresses into state.
- Window backdrops: a translucent window on macOS, and Mica or Acrylic in the frame on Windows 11.
download, which saves a file from the web in the background and reports its progress into state: a percentage, a status, the bytes received, the file’s size and the speed, whichformat_bytesshows as text such as1.5 MB. Each download has an id, which keeps its reports with its list item and whichpause_download,resume_downloadandcancel_downloadtake; resuming continues with an HTTP range request, or starts over when the server can’t send part of a file. A download can fetch its file over several connections at once, be saved under a name the app chooses, send request headers, and retry failed connections after a wait.- File dialogs:
pick_file,pick_files,pick_folderandpick_save_pathask the user for a path with the system’s own Open and Save dialogs. nf create,nf check,nf run, andnf dev, which reloads the app on every save and keeps its state.nf build, which compiles your app to machine code and packages it to share.- Editor support in VS Code and Zed through
nf lsp.
In progress
- Download control: several connections per file, choosing the file name, and request headers and retries (#18).
Not supported yet
Platforms and tooling
- A Linux (GTK) backend, so
nf build --linuxworks too. nf buildon Windows without installing Microsoft’s C++ Build Tools first, by shipping a linker withnf.- Mica and Acrylic behind the content of Windows windows, not just their frame. The system’s own controls can’t draw on a see-through background, so this waits on Nifty drawing its own controls.
Language
- State that belongs to one view, instead of the whole app.
- Passing bindings into views, so your own views can edit values.
- Keyed
forloops that keep unchanged rows instead of rebuilding them. - Enums and
match. Until then, a choice such as a filter is a string. - Constants. Until then, a named value is a function that returns it.
- Functions as values (closures).
- More list and string methods. Lists and strings have
lenandis_empty, and lists havepushandremove; filtering, counting, sorting and searching are loops for now, and wait on closures.
UI
- Images that come with an app, which
nf buildbundles and the app finds next to its code. Images read from full paths, and paths from~, work today. - Custom drawing.
Doing more
- More background work, networking and files, building on
download: other requests, and reading and writing files.
Have an idea?
Open an issue. The language specification records why the syntax looks the way it does, so check there first if you’re proposing a change to it.