Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 dev reloads 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

How this book is organized

PartRead it when…
Getting startedyou want Nifty installed and an app on screen.
Tutorialsyou’d like to learn by building something, one step at a time.
The guideyou want each idea explained properly, with examples.
Referenceyou know what you’re looking for and need the exact details.
Under the hoodyou’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

SystemCommand
macOSbrew upgrade nifty-cli
Windowswinget 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 seeWhat 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 missingnf 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:

  1. state count = 0 creates a value called count that starts at 0. State is the data your app remembers. When it changes, the screen updates.
  2. window("hello", …) makes a window with the title “hello”, 320 points wide and 200 tall.
  3. column(…) stacks things top to bottom. row(…) puts them side by side.
  4. label("Clicked {count} times", …) shows text. The {count} part is replaced by the current value of count.
  5. 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 dev running 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 hello then just nf run or nf check.

What’s next

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

FeatureVS CodeZed
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:

  1. Build it once:

    cd editors/vscode
    npm install
    npm run compile
    
  2. Open the editors/vscode folder in VS Code and press F5. A second VS Code window opens with Nifty support turned on. Open any .nf file 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.

  1. In Zed, open the command palette (⇧⌘P on macOS, Ctrl+Shift+P on Windows) and run zed: install dev extension.
  2. Pick the editors/zed folder.

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

  1. Checks your app. If there are errors, it shows them and builds nothing, just like nf run.
  2. Compiles it to machine code, and links that with Nifty’s runtime library into one program. Your .nf files aren’t inside the app.
  3. Makes the app in a build folder next to your main.nf, named after the project folder (todos becomes todos.app on macOS, or todos\todos.exe on Windows). If you build a single file such as demo.nf, the app is called demo.
  4. 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 = 0 makes a piece of state called count that starts at 0. State is the data your app remembers while it runs.
  • Inside the text, {count} is replaced by the value of count. 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() { … }, not column { … }.

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:

SettingWhat it does
spacing: 1212 points of space between children.
align: .centerCenters children across the column (left to right).
justify: .centerCenters children along the column (top to bottom).
style: .titleBig, bold title text.
style: .primaryMakes 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 of Todos”.
  • = [] 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.done is 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, …) shows draft and 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.”

  • fn declares 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 to draft.

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
}
  • -> int says this function gives back a whole number.
  • let count = 0 makes a local variable that only exists while the function runs.
  • !todo.done means “not done”.
  • return count hands 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

TypeWhat it holdsExamples
intwhole numbers0, 42, -7, 1_000_000
floatnumbers with a decimal point3.14, 0.5, -2.0
boolyes or notrue, false
stringtext"hello", ""
[T]a list of Ts[1, 2, 3], ["a", "b"]
a struct nameyour own record typeTodo(title: "Milk", done: false)

A few details:

  • Underscores in numbers are just for reading: 1_000 is 1000.
  • A float needs digits on both sides of the dot: write 0.5, not .5, and 2.0, not 2..
  • There is no null or “nothing” value. If something is a string, 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:

WriteTo get
\na new line
\ta 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):

OperatorMeaning
a || ba or b
a && ba and b
== != < <= > >=equal, not equal, less than, and so on
+ -add, subtract (+ also joins two strings)
* / %multiply, divide, remainder
!a, -anot, 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:

  1. In a button’s action (or a function it calls):

    button("Reset") { count = 0 }
    
  2. 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:

WriteDoes
items[0]the first item (positions start at 0)
items[i] = xreplace 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:

ElementIts block holds
window, column, row, scrollthe children to show inside it
buttonthe action: code that runs when it’s clicked
text_fieldan optional action: code that runs when Return is pressed in it
drop_targetan 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.

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)
  }
}

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: 12 and width: 320,
  • options like style: .title and align: .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:

  • column stacks children top to bottom.
  • row places 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:

SettingWhat it doesExample
spacingspace between children, in pointsspacing: 8
paddingspace around the edge, in pointspadding: 20
alignwhere children sit across the stackalign: .center
justifywhere children sit along the stackjustify: .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…
columnup and downleft and right
rowleft and rightup and down

align options:

OptionChildren…
.stretch (default)stretch to fill the width of a column (or height of a row)
.startsit at the left of a column (or top of a row)
.centersit in the middle
.endsit at the right of a column (or bottom of a row)

justify options:

OptionChildren…
.start (default)bunch up at the start
.centerbunch up in the middle
.endbunch up at the end
.space_betweenspread 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:

SettingWhat it doesExample
widtha fixed width, in pointswidth: 200
max_widthfill the width on offer, up to this many pointsmax_width: 600
growtake the space the parent has left along its directiongrow: true

A window can’t be made narrower than its content, so a fixed-width sidebar and a text field never get squashed.

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)
        }
      }
    }
  }
}
  • .sidebar is the side panel. On macOS it’s translucent and runs up under the title bar, like Finder’s. Fill it with nav_item rows, which highlight the one selection holds.
  • .content is the main pane, in the color content is read on.
  • .card is 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.

StyleUse it for
.large_titlethe name of what the window shows
.titlethe main heading
.headlinea bold section heading
.body (default)normal text
.captionsmall, 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:

  1. State → control: the field shows draft. If code changes draft (say, a button sets draft = ""), the field changes too.
  2. Control → state: when someone types, draft changes. The label above updates as they type.

What can be bound

ControlBinds 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

  1. Before your app runs, Nifty finds out which state each part of the UI reads.
  2. 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 +:

  • count changes.
  • The second label reads count, so it gets its new text.
  • The if reads count, 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 UIWhen 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
ifit’s checked again; its contents are rebuilt in place if needed
forits rows are rebuilt in place
A viewit’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:11 is 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

WhenWhat finds itExample
Before runningnf check, nf run, nf dev and nf builda typo in a name, the wrong type
While runningnf run and nf devdividing 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

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 +.
  • int and float don’t mix.
  • Only button actions, bindings, download and its controls, and the pick_… 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 0 and 10000, e.g. 12.
  • fixed int: a whole number written directly, between -1000000000 and 1000000000, 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: true or false, 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) { … }
SettingKind
(first) titlefixed textrequired
widthnumberdefault 480
heightnumberdefault 320
backdropoption.standard (default), .translucent, .solid
openflagtrue (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:

backdropmacOSWindows 11
.standardthe usual windowMica in the title bar and frame
.translucentthe whole window shows a blurred view of what’s behind it, where no surface covers itAcrylic in the title bar and frame
.solidthe usual windowno 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) { … }
SettingKind
spacingnumberspace between children
paddingnumberspace around the edge
alignoption.stretch (default), .start, .center, .end
justifyoption.start (default), .center, .end, .space_between
surfaceoption.none (default), .sidebar, .content, .card; what the stack is drawn on
widthnumbera fixed width; without it the stack fits its content
max_widthnumberthe stack fills the width it’s given, up to this
growflagfalse (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:

SurfaceUse it formacOSWindows
.sidebara window’s side paneltranslucent sidebar material that shows the desktop through ita solid panel a shade apart from the window
.contentthe window’s main panethe color content is read on: white, or dark in dark modeFluent’s content layer color
.cardgrouping related contenta rounded panel with a thin outlinea 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.

macOSWindows
Scrollingthe trackpad, mouse wheel or scroll bar, with the system’s elastic bouncethe mouse wheel, by as many lines a notch as the user set, or the scroll bar
Scroll barfloats over the content, and shows while scrolling; with Show scroll bars: Always, it takes a strip beside the content and always showstakes 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)
SettingKind
(first) texttextrequired
styleoption.body (default), .large_title, .title, .headline, .caption
coloroption.primary, .secondary, .tertiary, .accent, .success, .warning, .danger
alignoption.start (default), .center, .end
selectableflagfalse (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) }
SettingKind
(first) titletextrequired
styleoption.normal (default), .primary, .plain, .icon
iconoptionan icon shown before the title
selectedboolshows the button as selected while it holds; stays up to date
StyleLooks like
.normala standard push button
.primarythe window’s main action, in the accent color: pressing Return clicks it, unless the focus is in a text field with its own action
.plainborderless until the pointer is over it, in the accent color, for quieter actions such as those in a row
.iconborderless 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.

macOSWindows
Selected .normalthe system’s on-off push button, shown onFluent’s checked toggle button, in the accent color
Selected .plain, .iconan on-off accessory bar button, shown ona subtle fill, with the title or icon in the accent color
Screen readersan on-off buttona 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)
SettingKind
(first) valuebinding to a stringrequired
placeholderfixed textgrey hint shown while empty; one-line fields only
linesfixed inthow 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.

macOSWindows
One linethe system’s text fieldthe system’s edit control
Several linesa text view in a bordered scroll view, as in Mail’s message bodya multi-line edit control with a scroll bar
Tab in several linestypes a tab; Ctrl+Tab moves to the next controlthe 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)
SettingKind
checkedbinding to a boolrequired
titletextthe 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")
SettingKind
onbinding to a boolrequired
titletextshown 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.

macOSWindows
Switchthe system’s NSSwitchFluent’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)
SettingKind
(first) valuebinding to an intrequired
minfixed intthe left end; default 0
maxfixed intthe 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.

macOSWindows
Sliderthe system’s NSSliderthe 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)
SettingKind
(first) selectionbinding to a stringrequired; the option shown, and where a pick goes
optionslist of textrequired; 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.

macOSWindows
Pickerthe system’s pop-up buttona 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.


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)
SettingKind
(first) titletextrequired
valuetextrequired; what selection holds while this item is picked
selectionbinding to a stringrequired; shared by the items of one list
iconoptionan icon, drawn in the accent color
badgetextshown 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.

macOSWindows
Row28 points, Finder’s sidebar row32 pixels at 100%, Fluent’s navigation item
Selecteda rounded highlight in the system’s selection colora subtle highlight with an accent mark at its start
Under the pointer, presseda fainter highlight, and a stronger one while pressedthe 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)
SettingKind
(first) pathtextrequired; where the file is; stays up to date
widthnumbera fixed width; the height follows the image’s shape
heightnumbera 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.

macOSWindows
Formatswhatever the system reads: PNG, JPEG, GIF, HEIC, TIFF, PDF and morewhat the Windows Imaging Component reads: PNG, JPEG, GIF, BMP, TIFF and ICO, and more with codecs installed
Drawingthe system’s image view, scaling smoothlyscaled 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()
SettingKind
valueinthow 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.

macOSWindows
With valuethe system’s bar, in the accent colorthe system’s progress bar
Withoutthe bar’s animated stripethe 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.

macOSWindows
Opens ona right-click or a Control-clicka 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 labelskeep their own menu, with Copy and the likekeep 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")
}
SettingKind
(first) droppedbinding 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.

macOSWindows
Filesfrom the Finder and other apps, as /Users/… pathsfrom File Explorer and other apps, as C:\… paths
Linksdragged from a browser’s address bar or page, as their addressfrom browsers that give a dragged link as its address, as Edge, Chrome and Firefox do
Text fieldsa text field under the pointer takes the drop itself, as texta text field under the pointer takes the drop itself, as text

Block: an action run after each drop (optional). It may change state.


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" }
  }
}
SettingKind
(first) titlefixed textrequired

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.

macOSWindows
Wherethe 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 menusthe menu bar of every window, holding your menus in order; text fields take their editing shortcuts themselves
Lookthe system’s ownWin32’s own, which stays light in dark mode, as classic Windows menu bars do

Block: the entries (required).


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() }
SettingKind
(first) titlefixed textrequired
shortcutfixed texta 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}")
SettingKind
(first) urlstringrequired; an http:// or https:// address
tostringrequired; the folder to save into, created if missing
progressbinding to an intrequired; the percentage done, 0 to 100
statusbinding to a stringrequired; where the download stands, in words
receivedbinding to an intthe bytes saved so far
totalbinding to an intthe file’s size in bytes, once the server says; 0 until then
speedbinding to an intbytes per second, averaged over the last two seconds
idbinding to a stringthe download’s id, for pausing, resuming and cancelling; needed when the bindings are in a list item
namestringthe name to save the file as, made safe the same way as a name from the address; "", the default, names it after the address
connectionsinthow 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
retriesinthow 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:

Whenprogressstatus
Whenprogressstatus
———
right after the call0Downloading…
as data arrives0 to 100unchanged
when it’s saved100Saved to /Users/you/Downloads/report.pdf, or Saved to C:\Users\you\Downloads\report.pdf on Windows
when it failsunchangedFailed: and the reason, such as http status: 404
while it waits to retryunchangedRetrying…
when it’s pausedunchangedPaused
when it’s resumedas data arrivesDownloading…
when it’s cancelledunchangedCancelled

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.

macOSWindows
Cancellingthe partly saved file is removed at oncethe 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 .part file before the new version starts. Their state keeps its last value, so a status still says Downloading… or Paused, 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 .part files as the app quits. On macOS the app ends without waiting, so a download still running or paused leaves its .part file 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) }
}
FunctionDoes
pause_download(id)(first) the id, a stringstops 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 stringcarries on from where the paused download stopped; status goes back to Downloading…
cancel_download(id)(first) the id, a stringstops 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.

macOSWindows
A paused download’s filereport.pdf.part, in the folder it is saving tothe same
Cancellingthe partly saved file is removed at oncethe 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
SettingKind
(first) bytesintrequired; 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)
FunctionAsks forBinding
pick_fileone existing filea string
pick_filesone or more existing filesa [string]
pick_folderone existing foldera string
pick_save_pathwhere to save a new file; it may already exista string
SettingKind
(first)bindingrequired; where the chosen path goes
titlestringtext 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
namestringpick_save_path only: the file name it suggests
fromstringthe 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:

macOSWindows
Looks likea sheet sliding down from the active window, or its own window when none is activethe system’s Open or Save As dialog, in front of the active window, which waits until it closes
titleshown as a message at the top of the panelthe dialog’s title bar
typesother files are greyed out; saving adds the first type’s extensiona filter listing the types; saving adds the first type’s extension
fromignored when the folder doesn’t existignored 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 nameopens 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 namecloses 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.

macOSWindows
New windowcentered on the screen, in front, with the keyboardcentered on its monitor, in front, with the keyboard
Bringing an open window to the frontit comes to the front with the keyboardit 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 state keeps 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 dev prints 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 dev prints it and the app keeps running, so your next save can fix it. (nf run stops 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 build folder next to main.nf, named after the project folder (or after the file, for a single .nf file). Building again replaces it.
  • It builds for the system you’re on, which is also the default with no flag: on macOS, a .app bundle; on Windows, a todos folder holding todos.exe. There’s no Linux backend yet, so --linux doesn’t work.
  • The app is one compiled program, linked with Nifty’s runtime library; the project’s .nf files 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:

CodeMeaning
0everything worked
1your program has errors (found by checking, or while running)
2the 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, struct or state declared 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.nf marks the project and must exist. Windows can be declared in any file.
  • A subfolder with its own main.nf is a separate project, not part of this one. That includes the apps nf build writes into build/.
  • A single .nf file given to a command, such as nf run demo.nf, is an app on its own, even when it sits inside a project folder.
CommandDoes
nf create <name>creates <name>/main.nf from the starter app
nf check [project]reports syntax and type errors without running anything
nf lspruns 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 use snake_case, and types and views use PascalCase.
  • 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

TypeValues
int64-bit signed integer
float64-bit float
booltrue, false
stringimmutable UTF-8 text
[T]growable list of T
Namea 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) { ... }
  • state declares 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.
  • fn declares a function. Its return type defaults to “nothing”.
  • view declares 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.
  • struct declares 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, for open_window(settings) and close_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 items also names each item’s position, an int counting from 0.
  • for i in start..end counts i from start up to, but not including, end; both are ints, worked out once before the loop starts. When end isn’t above start, 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.
  • .name is an implicit member: the value comes from the expected type, so align: .center means the center alignment.
  • $place creates 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 a for variable looping over state, optionally followed by fields and list items.
  • Values are copied on assignment: after b = a, changing b leaves a alone. A for variable looping over a place is the exception: it stands for each element, so for todo in todos { todo.done = true } changes todos.
  • 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) and cancel_download(id) control one, format_bytes(n) shows a size such as 1.5 MB, and pick_file, pick_files, pick_folder and pick_save_path ask the user for a path with the system’s file dialog, and open_window and close_window open 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

ElementArgumentsTrailing 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 itchildren
column(spacing:, padding:, align:, justify:, surface:, width:, max_width:, grow:)all optionalchildren
row(spacing:, padding:, align:, justify:, surface:, width:, max_width:, grow:)all optionalchildren
scroll(spacing:, padding:, align:, justify:, surface:, width:, max_width:)a column that takes the space left and scrolls its children; see scrollchildren
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 buttonaction
text_field($value, placeholder:, lines:)lines, a whole number from 1 (default) to 100, makes it that many lines tallaction 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 menuentries
menu_item(title, shortcut:)in a menu; shortcut such as "n" or "shift+n", with Cmd on macOS and Ctrl on Windowsaction
context_menu()among a stack’s children; shown when the stack is right-clicked; see context_menuentries
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_targetaction 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.

#QuestionChosenAlternativeWhy
1Parentheses on elementslabel("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() { … }.
2Where element settings goArguments: 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.
3Enum valuesalign: .centeralign: 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.
4Named windowswindow("Counter"), and window main("Counter") { … } for one code opens or closeswindow 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.
5Apps bigger than one fileEvery .nf file in the project folder is part of the app$include "ui/header.nf", or import between your own filesTop-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.
6View parameters that edit a valueview 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.
7State that belongs to one viewstate at the start of a view’s bodyA separate local keywordThe 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.
8Keyed forfor todo in todos by todo.id { … }for todo in todos, key: todo.id, or keying any struct with an id field automaticallyOne short keyword, and keys are always written down. A for without by keeps rebuilding rows as it does today.
9Enumsenum Load { idle, loading(percent: int), failed(message: string) }, values written .idle or Load.idlePascalCase casessnake_case cases match the built-in .center and .space_between. Built-in options become ordinary enums, so .name works the same way for both.
10match.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.
11Functions as valuesType 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()).
12Loop positions and countingfor index, item in items { … }, and for i in 0..n { … } counting from 0 up to but not including nitems.enumerate() and range(0, n); Swift’s 0..<n; an inclusive 0...nBoth 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 ..=.
13A switch’s bindingswitch(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.
14A slider’s rangeslider($volume, min: 0, max: 10), whole numbers written literallyA range expression, or float valuesRanges 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.
15A picker’s optionspicker($speed, options: speeds), any [string] expressionOptions written as a block of elementsOptions 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.
16Picking one of a few choicesA 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.
17Several lines of texttext_field($notes, lines: 4)A separate text_area($notes) elementIt 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.
18Where an image comes fromimage(path, width:, height:), a full path, or one from ~image(named: "logo") for images that come with the appA 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.
19A text field’s actionA trailing block: text_field($query) { search() }, run on Returnon_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.
20Menus and shortcutsTop-level menu("File") { menu_item("New", shortcut: "n") { … } }, with divider() and nested menusMenus 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.
21Context menusA context_menu() { … } child of the stack it belongs to, holding the same menu_items, divider()s and submenus as a menuA context_menu: setting on each element, or a block on menu with a context: true flagA 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.
22Opening and closing windowsopen_window(settings) and close_window(settings), with open: false on a window that waits for codeMethods on the name, settings.open(), or a bool state bound to each windowBuilt-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.
23Dropping files and linksA drop_target($dropped) { … } child of the stack it belongs to, writing a [string] of paths and addresses, then running its blockA drop: setting on stacks, or a callback that receives the list as a parameterIt 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.
24Referring to a running downloaddownload(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 iddownload returning a handle value, or an int id, or bindings that track list positions as items are removedAn 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.
25Downloading over several connectionsdownload(url, to:, connections: 4, …), an int setting that defaults to 1A parallel: true flag with a fixed count, or the runtime choosing a count on its ownHow 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.
26Choosing a download’s file namedownload(url, to:, name: "report.pdf", …), a string setting where "", the default, names the file after the addressA full path in to:, or a save_as: settingThe 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.
27Request headersdownload(url, to:, headers: ["Authorization: Bearer {token}"], …), a [string] with each header written Name: valueA map or record type, such as headers: {"Authorization": token}, or a header per settingNifty 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.
28Retrying failed connectionsdownload(url, to:, retries: 3, …), an int count that defaults to 0, with a fixed backoff of 1 second doubling to at most 30Settings for the delays, or retrying by defaultA 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.

  1. 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.
  2. 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 count points at the one state count;
  • works out types and makes sure they fit, such as not adding int to float;
  • checks elements: every label, button and the rest gets the arguments it accepts, using the single table in elements.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, a for, 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:

  1. sets every state to its starting value;
  2. mounts each window: walks the UI description and asks the runtime to create a native widget for each element;
  3. waits for events. When you click a button, it runs the action, and notes which state changed;
  4. updates only the UI parts whose recorded dependencies changed: new text for a label, or rebuilding an if, for or 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.

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 source link 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

FileWhat’s in it
lexer.rssplits text into tokens; decides which line breaks matter
token.rsthe token kinds and the keyword list
parser/the parser, split into items, statements and expressions
ast.rsthe syntax tree types
diagnostic.rs, span.rserror messages and source positions, each in one file of the project
project.rswhich .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

FileWhat’s in it
elements.rsthe one table of built-in UI elements and their arguments
functions.rsthe one table of built-in functions, such as download
methods.rsthe one table of built-in list and string methods
hir.rsthe checked program that the interpreter runs
checker/the checker: declarations, statements, expressions, UI, arguments, constants
tests/grammars.rschecks 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

FileWhat’s in it
app.rsstarts and reloads a program on the engine
lower.rsturns checked windows and views into the engine’s templates
logic.rsthe interpreter as the engine’s Logic: text, bindings, actions and regions
live.rsan app whose program can be swapped while it runs, for nf dev
machine/carry.rswhich state values survive a reload
machine/evaluates statements and expressions; reads and writes places
value.rsruntime 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

FileWhat’s in it
compile/compiles a checked program and emits the descriptor the runtime reads
compile/body.rsstatements, expressions and places, with the reference counting that keeps copies independent
compile/ui.rswindows, views, templates, and the functions behind their dynamic parts
emit.rswrites functions and data into an object file, or into memory for tests
object.rsan app’s object file, with the main that runs it
link.rslinks an object and the runtime library into an executable
tests/compiled.rsruns programs compiled and interpreted side by side, and checks they match
tests/link.rscompiles, links and runs a program for real; build the workspace first

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

FileWhat’s in it
program.rsthe descriptor compiled programs emit, whose layout is the contract with nifty-codegen
value.rsvalues in 64-bit slots; reference-counted strings, lists and structs
abi/the nf_* calls, and who owns the values passed to them
context.rsstate, call depth, and why code failed
logic.rs, lower.rs, show.rscompiled code as the engine’s Logic
place.rsplaces in state that later writes go to, as compiled code names them
download.rs, dialog.rsdownload and the file dialogs in compiled apps
symbols.rsthe 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

FileWhat’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.rswhich state UI reads; why running code fails, the same however it runs
ui.rsthe retained UI: create, arrange and update widgets
backend.rsthe Backend trait a platform implements, and the Waker other threads use to reach the UI thread
dialog.rs, paths.rsfile dialogs: what an app asks for, and the queue that shows them between events and hands back the answers; ~ in paths
layout.rsflexbox layout with Taffy
style.rs, geometry.rstext/button/stack styles; points, sizes, frames
testing.rsan 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

FileWhat’s in it
server.rsthe request loop
workspace.rsopen files and the projects they belong to
project.rsa project’s files, analysed together
document.rsone 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

FileWhat’s in it
lib.rsthe usage text; parses the command line and dispatches
main.rsthe entry point
create.rs, run.rs, dev.rs, check.rs, build.rs, lsp.rsone file per command
build/macos.rswrites the .app bundle around the compiled executable
build/windows.rswrites the Windows app folder around the compiled executable
frontend.rsreads a project, checks it, and says where spans are; shared by every command
report.rsprints errors with the source line underneath
templates/main.nfthe starter app nf create writes

Editors

FolderWhat’s in it
editors/vscodeVS Code extension: TextMate grammar + language client
editors/zedZed extension: Tree-sitter queries + language server setup
editors/tree-sitter-niftythe 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 .nf file that shows the problem,
  • what you expected, and what happened instead (paste the full output of nf check or nf 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 syntaxcrates/nifty-syntax (then the grammars)
add or change a type rule or error messagecrates/nifty-check/src/checker
add a UI elementcrates/nifty-check/src/elements.rs, then the runtime and both backends
add a built-in function or methodcrates/nifty-check/src/functions.rs or methods.rs, then crates/nifty-interp
change how apps runcrates/nifty-interp
change a native widgetcrates/nifty-macos and crates/nifty-windows
add an editor featurecrates/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 nf so they get syntax colors.
  • Show a whole app with {{#include}} from fixtures/ 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.rb to the istorry/homebrew-tap repository, made by packaging/homebrew/formula.sh;
  • opens a pull request on microsoft/winget-pkgs with the manifests from packaging/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:

SecretWhat it is
HOMEBREW_TAP_TOKENa fine-grained token with Contents: read and write on istorry/homebrew-tap
WINGET_TOKENa 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

macOSWindowsLinux
Run apps: nf run, nf devYes, with AppKitYes, with Win32Not yet
Share apps: nf buildA .app; needs Xcode’s command line toolsA folder with an .exe; needs Microsoft’s C++ Build ToolsNot yet
Install nfHomebrewwingetNot yet
Window backdropsA translucent windowMica or Acrylic in the frame, on Windows 11 22H2 and laterNot yet
IconsSF SymbolsSegoe Fluent Icons on Windows 11, Segoe MDL2 Assets on Windows 10Not 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 .nf file in the project folder is part of the app, in the CLI, nf dev reloads 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, which format_bytes shows as text such as 1.5 MB. Each download has an id, which keeps its reports with its list item and which pause_download, resume_download and cancel_download take; 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_folder and pick_save_path ask the user for a path with the system’s own Open and Save dialogs.
  • nf create, nf check, nf run, and nf 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 --linux works too.
  • nf build on Windows without installing Microsoft’s C++ Build Tools first, by shipping a linker with nf.
  • 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 for loops 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 len and is_empty, and lists have push and remove; filtering, counting, sorting and searching are loops for now, and wait on closures.

UI

  • Images that come with an app, which nf build bundles 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.