How to make an interactive picker for a Swift command-line tool

Sponsored

Helm logo
Helm for App Store Connect

A native app for App Store Connect to make managing your apps faster, simpler and more fun. Get early access now!

I have been writing command-line tools with Swift for a while now and, while I find the developer experience to be great, I have always found the number of libraries to handle user input to be lacking compared to other languages such as Javascript.

This sparked my interest in learning more about how such libraries work and investigating how hard it would be to make something similar using Swift.

In this post, I will show you how to make an interactive picker for a command-line tool using ANSI escape sequences inspired by one of my favourite javascript libraries: clack by Nate Moore.

In fact, I like the library so much that I have started working on migrating it to Swift and I am going to document my progress in a series of posts.

What are ANSI escape sequences?

ANSI escape sequences are a way to perform operations in the terminal through a series of predefined characters. These sequences are supported by terminals in Unix operating systems (like macOS) and Windows (only from Windows 10) and are used by many command-line tools to perform actions such as changing the text colour, modifying the cursor position, clearing the screen, etc.

For example, the following escape sequence changes the text colour to red:

ANSI.swift
print("\u{1B}[31mHello, world!")

The sequence above starts with the escape character: \u{1B}. This is followed by the character [ and a number that represents the colour we want to use. In this case, 31 is the code for red. Finally, we end the sequence with the character m followed by the text we want to display.

Let’s now make our example a bit more complex by making the text bold. We can do this by adding the number 1 followed by a semicolon before the colour code:

ANSI.swift
print("\u{1B}[1;31mHello, world!")

As you can see, escape sequences are nothing more than the combination of characters and string manipulation to achieve the desired formatting.

Creating an executable

To make an interactive command-line component, we first need to create an executable Swift package using the following command:

Terminal
swift package init --type executable

Importing ANSITerminal

The reason we need to create a Swift package instead of a Swift script is that we will need to import a dependency to make working with ANSI escape sequences easier: ANSITerminal.

ANSITerminal is distributed as a Swift Package, so we can add it to our project by declaring it as a dependency to the executable target in our Package.swift file:

Package.swift
// swift-tools-version: 5.7

import PackageDescription

let package = Package(
    name: "Picker",
    products: [
        .executable(name: "Picker", targets: ["Picker"])
    ],
    dependencies: [
         .package(url: "https://github.com/pakLebah/ANSITerminal.git", exact: "0.0.3"),
    ],
    targets: [
        .executableTarget(name: "Picker", dependencies: ["ANSITerminal"])
    ]
)

Getting started

Before we start writing some code, let’s take a look at the picker component we’ll be building in this article:

The picker, which is highly inspired by the select component in Nate Moore’s clack library, presents a number of options and allows the user to select one using a combination of the arrow keys and the enter key.

Once the user has selected an option, the picker returns the selected string, the icon is updated to a checkmark and the program exits gracefully.

Let’s start by importing the ANSITerminal library in the executable’s entry point, creating a picker function which contains the component’s code and invoking it with a title and a set of options:

Sources/Picker/main.swift
import ANSITerminal

func picker(title: String, options: [String]) -> String {
    return "Hello World"
}

_ = picker(title: "Choose your favourite fruit", options: ["🍌 Banana", "🥝 Kiwi", "🍓 Strawberry"])

Note that in the following sections, unless I specifically say otherwise, all functions I will be calling in the picker method are either part of Foundation or ANSITerminal.

Showing the picker’s title

The first thing we need to do is to write the picker’s title on the terminal’s screen:

Sources/Picker/main.swift
func picker(title: String, options: [String]) -> String {
    // 1
    cursorOff()
    // 2
    moveLineDown()
    // 3
    write("◆".foreColor(81).bold)
    // 4
    moveRight()
    write(title)

    return "Hello World"
}

When the picker function is called, the code above will:

  1. Turn the cursor off to avoid having it displayed in the terminal as there will be no need for it.
  2. Move one line down from the current position. This is needed because we want to start writing the title on a new line.
  3. Write the icon for the picker. In this case, we are using the same Unicode character as clack uses for its prompts.
  4. Move the cursor one character to the right to add a white space and write the title.

Output

An image showing the title part of the picker

Handling state

The next step is to create a State class and an Option struct to handle the state of the picker:

Sources/Picker/main.swift
struct Option {
    let title: String
    let line: Int

    init(title: String, line: Int) {
        self.title = title
        self.line = line
    }
}

class OptionState {
    let options: [Option]
    let rangeOfLines: (minimum: Int, maximum: Int)
    var activeLine: Int = .zero

    init(options: [Option], activeLine: Int, rangeOfLines: (minimum: Int, maximum: Int)) {
        self.activeLine = activeLine
        self.rangeOfLines = rangeOfLines
        self.options = options
    }
}

This state will hold the line number for the selected option and the range of lines where the options are displayed.

Let’s create an instance of the OptionState class and feed it an initial state derived from the set of options passed to the picker function:

Sources/Picker/main.swift
func picker(title: String, options: [String]) -> String {
    // ...

    // 1
    let currentLine = readCursorPos().row + 1

    // 2
    let state = OptionState(
        options: options.enumerated().map { Option(title: $1, line: currentLine + $0) },
        activeLine: currentLine,
        rangeOfLines: (currentLine, currentLine + options.count - 1)
    )

    return "Hello World"
}

Let’s break down the code above into a number of steps:

  1. Read the cursor’s current row. Then add one to make sure that the picker’s first option is displayed on a new line.
  2. Create the initial state for the picker. Use the enumerated() method to get the index of each option and add it to the current line to get the line where the option will be displayed.

Displaying all available options

Now that we have a state and the lines where our options need to be displayed, let’s write them to the terminal:

Sources/Picker/main.swift
func picker(title: String, options: [String]) -> String {
    // ...

    // 1
    options.forEach { optionTitle in
        // 2
        moveLineDown()

        // 3
        let isActive = readCursorPos().row == state.activeLine

        // 4
        write("│".foreColor(81))
        moveRight()
        if isActive {
            write("●".lightGreen)
        } else {
            write("○".foreColor(250))
        }

        // 5
        moveRight()
        if isActive {
            write(optionTitle)
        } else {
            write(optionTitle.foreColor(250))
        }
    }

    // 6
    moveLineDown()
    let bottomLine = readCursorPos().row
    write("└".foreColor(81))

    return "Hello World"
}

The code to render all options in the terminal can be summarised as follows:

  1. Iterate through the array of options.
  2. Move the cursor down so that text is written on a new line.
  3. Check if the current option is the active one.
  4. Write the icon for the option. If the option is active, use a green circle, otherwise, use a grey circle, similarly to what clack does.
  5. Move right by one character to leave one whitespace between the icon and the text. If the option is active, write the title in white, otherwise, write it in grey.
  6. Move the cursor down one line and write the closing symbol of the sidebar in the same colour.

Output

An image showing the picker listing the full set of options

Handling user input

Now that we have a picker that displays a set of options, we need to allow the user to select one of them.

To do so, we need to halt the program’s execution, read the user’s input and perform the appropriate action depending on the pressed key:

  • If the user presses the up arrow key, move the active option up. If the active option is already at the top, don’t do anything.
  • If the user presses the down arrow key, move the active option down. If the active option is already at the bottom, don’t do anything.
  • If the user presses the enter key, return the selected option and allow the program’s execution to continue.

Let’s now write some code that fulfils the above requirements:

Sources/Picker/main.swift
func picker(title: String, options: [String]) -> String {
    // ...

    // 1
    while true {
        // 2
        clearBuffer()

        // 3
        if keyPressed() {
            // 4
            let char = readChar()
            if char == NonPrintableChar.enter.char() {
                break
            }

            // 5
            let key = readKey()
            // 6
            if key.code == .up {
                if state.activeLine > state.rangeOfLines.minimum {
                    state.activeLine -= 1

                    reRender()
                }
            // 7
            } else if key.code == .down {
                if state.activeLine < state.rangeOfLines.maximum {
                    state.activeLine += 1

                    reRender()
                }

            }
        }
    }

    return "Hello World"
}

When called, the picker method now listens for user input by doing the following:

  1. Start an infinite loop that will only be broken when the user presses the enter key.
  2. Clear the terminal’s buffer.
  3. Check if the user has pressed a key.
  4. If the user has pressed the enter key, break the loop.
  5. Read the key that the user has pressed.
  6. If the user has pressed the up arrow key, move the active option up and re-render the options. If the active option is already at the top, don’t do anything.
  7. If the user has pressed the down arrow key, move the active option down and re-render the options. If the active option is already at the bottom, don’t do anything.

Re-rendering the options

Let’s now implement the reRender() function used in the previous section. This function changes the colour of each option’s title and updates the circle icon next to it based on whether the option is selected or not:

Sources/Picker/main.swift
func reRender(state: OptionState) {
    // 1
    (state.rangeOfLines.minimum...state.rangeOfLines.maximum).forEach { line in
        // 2
        let isActive = line == state.activeLine

        // 3
        let stateIndicator = isActive ? "●".lightGreen : "○".foreColor(250)
        writeAt(line, 3, stateIndicator)

        // 4
        if let title = state.options.first(where: { $0.line == line })?.title {
            let title = isActive ? title : title.foreColor(250)
            writeAt(line, 5, title)
        }
    }
}

// Helper method
func writeAt(_ row: Int, _ col: Int, _ text: String) {
    moveTo(row, col)
    write(text)
}

Next time the programme is executed and the user presses the up or down arrow keys, the reRender method will:

  1. Iterate through the range of lines where the options are displayed.
  2. Check if the current line is the active one.
  3. If the current line is the active one, write a green circle, otherwise, write a grey circle.
  4. If the current line is the active one, write the option’s title in white, otherwise, write it in grey.

Output

Option selected state

Now that the user has selected an option, we want to update the picker’s icon to a checkmark, change the colour of the sidebar to not be blue to denote that the component is no longer active, remove the closing bracket from the bottom of the sidebar and turn the cursor back on:

Sources/Picker/main.Swift
func picker(title: String, options: [String]) -> String {
    // ...

    let startLine = currentLine - 1
    writeAt(startLine, 0, "✔".green)

    (startLine + 1...bottomLine).forEach { writeAt($0, 0, "│".foreColor(252)) }

    moveTo(bottomLine, 0)

    return "Hello World"
}

Output

Returning the selected option

Last but not least, the method needs to return the selected string after the user has pressed the enter key. To do so, we need to find the option that is currently active and return its title:

Sources/Picker/main.swift
func picker(title: String, options: [String]) -> String {
    // ...

    return state.options.first(where: { $0.line == state.activeLine })!.title
}

That’s it! 🎉 We have written an interactive picker for the command-line in Swift.

I am planning on writing more posts on this topic as I build up my Swift port of the clack library, so stay tuned 👀.