Building A Terminal User Interface With Golang
We’re Earthly. We make building software simpler and therefore faster. Earthly is open-source and written in go. So if you’re interested in a simpler way to build then check us out.
Did you know it’s actually possible to build a rich UI that runs completely in the terminal? Programs like htop and tmux use a terminal user interface (TUI) because they are often run on servers that don’t have access to a GUI. Many developer tools also a TUI since developers spend so much time in the terminal anyway. There are even a number of games that run entirely in the terminal. In this article we’ll use the Go programing language to create our own TUI.
I first became interested in terminal user interfaces when I started using K9s to help manage multiple Kubernetes clusters. K9s runs entirely in the terminal and has a robust set of features and commands. It allows you to manage multiple clusters by displaying pods and nodes in an interactive real time table view. It also gives you the ability to run kubectl
commands with the click of a button.
There are a handful of different packages to help you create a TUI in Go, and they all offer different advantages. But since K9s is what led me here, I decided to do a deeper dive into the library they use which is called Tview.
In addition to having a strong project behind it, the documentation for Tview
is pretty good, and there are a decent number of other example projects linked in their github repo, so it was relatively easy to get something up and running quickly.
In this post I want to highlight some of Tview
’s core functionality. In order to keep the focus on Tview
and its features, we’ll build a very simple app for storing and displaying contacts. Basically a terminal based rolodex. All the code is up on Github.
Widgets
Tview
is built on top of another Go package called tcell
. Tcell
provides an api for interacting with the terminal in Go. Tview
builds on top of it by offering some pre-built components it calls widgets. These widgets help you create common UI elements like lists, forms, dropdown menus, and tables. It also includes tools to help you build layouts with grids, flexboxes, and multiple pages.
Installing Tview
To install tview
run go get github.com/rivo/tview
and then you can import both tview
and tcell
in your main.go
file.
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
Next, we’ll create a struct
to hold our contact info and a slice
to hold multiple contacts.
type Contact struct {
string
firstName string
lastName string
email string
phoneNumber string
state bool
business
}
var contacts []Contact
Creating an App
The first tview
widget we’ll talk about is the Application
. This is the top level node of our app.
var app = tview.NewApplication()
func main() {
if err := app.SetRoot(tview.NewBox(), true).EnableMouse(true).Run(); err != nil {
panic(err)
} }
After creating a new app, we call a series of set up methods including enabling mouse detection and issuing a Run
command. The SetRoot
function tells the tview
app which widget to display when the application starts. In this case we’ve chosen to use a Box
widget. Boxes are the parent type of all other widgets. They can do nothing but exist, which is fine for now.
Run this code and marvel at the blank black nothing that fills your terminal screen. You can end the program by pressing ctrl+c
. Not super exciting, but it’s a start. We’ve created an app, but we haven’t told it to display anything or to respond to any key presses.
Adding Text
Let’s get rid of that blank box and replace it with a new widget called a TextView
.
var app = tview.NewApplication()
var text = tview.NewTextView().
SetTextColor(tcell.ColorGreen)."(q) to quit")
SetText(
func main() {
if err := app.SetRoot(text, true).EnableMouse(true).Run(); err != nil {
panic(err)
} }
Notice that after we created our new TextView
we use it to replace the blank box as the application root. Now run the code and behold: TEXT! The only problem is that our app is now lying to us because you can press q
all day and nothing is going to happen. Press ctrl-c
to quit for now and lets see if we can make an honest app out of this thing.
Responding To Input
Tview
allows us to respond to all sorts of events that are happening in the terminal, including mouse clicks, on focus actions, and yes, key presses. Let’s set up our app to quit when a user presses q
.
func(event *tcell.EventKey) *tcell.EventKey {
app.SetInputCapture(if event.Rune() == 113 {
app.Stop()
}return event
})
SetInputCapture
takes a function as an argument. That function gets passed an EventKey
type. This type holds information about the key press event. In this case, we can access the ascii
code for the key by calling Rune()
. If the key pressed is q
then we call Stop()
which will quit the application. Run the code again, press q
and the application should quit.
Capturing Data With Forms
Now that we can respond to input, let’s give the user the ability to create a new contact by providing them with a form to fill out. Tview
makes creating a form super simple.
var form = tview.NewForm()
And then, for reasons that will become clearer in a moment, we will create a function that populates the form with input fields.
func addContactForm() {
contact := Contact{}
"First Name", "", 20, nil, func(firstName string) {
form.AddInputField(
contact.firstName = firstName
})
"Last Name", "", 20, nil, func(lastName string) {
form.AddInputField(
contact.lastName = lastName
})
"Email", "", 20, nil, func(email string) {
form.AddInputField(
contact.email = email
})
"Phone", "", 20, nil, func(phone string) {
form.AddInputField(
contact.phoneNumber = phone
})
// states is a slice of state abbreviations. Code is in the repo.
"State", states, 0, func(state string, index int) {
form.AddDropDown(
contact.state = state
})
"Business", false, func(business bool) {
form.AddCheckbox(
contact.business = business
})
"Save", func() {
form.AddButton(append(contacts, contact)
contacts =
}) }
The code here is pretty straight forward. We can call a series of functions to add input items to the form. I tried to show off a few of the options available, but these are not all of them. (The states
variable that we are passing the drop down is just a slice of state abbreviations. I didn’t add the code here to save some space but it’s available in the repo.)
Each of these functions takes a few inputs depending on the type of input field you’re adding. All of them take a label as the first argument and then a changed
function as the last argument. The changed
function gets called whenever the input item changes. In this case we will use them to set the data for our contact.
The last thing we add is a button which we label Save
and then pass it a function that will execute when the button is pressed.
Now we can update our app to listen for a new button press and then call our addContactForm
function.
func(event *tcell.EventKey) *tcell.EventKey {
app.SetInputCapture(if event.Rune() == 113 {
app.Stop()else if event.Rune() == 97 {
}
addContactForm()
}return event
})
We can also update our text view with the info about the new command.
var text = tview.NewTextView().
SetTextColor(tcell.ColorGreen)."(a) to add a new contact \n(q) to quit") SetText(
Now if you run the application and press a
…nothing happens. This is because we need to tell our app
about our new form and give it a way to switch from the TextView
to the Form
.
Pages
When we originally started the app up we fed it a widget to use as its root, in this case a TextView
which we set the variable text
: app.SetRoot(text, true)
. Because the app is using the TextView
widget as its main primitive, anything we want to show on the screen needs to be attached to it. Or, we need to reset the app root, which is possible, but Tview
offers a special widget called Pages
to allow us to switch more easily between different views like pages on a website. Let’s refactor our code to uses a Pages
widget.
var pages = tview.NewPages()
And then down in our main function we can use it as the root.
if err := app.SetRoot(pages, true).EnableMouse(true).Run(); err != nil {
panic(err)
}
Now we can create a page for our Form
and a page for our TextView
and switch between them easily. In the main function add the following code.
"Menu", text, true, true)
pages.AddPage("Add Contact", form, true, false) pages.AddPage(
Then we can update our if statement to switch to the form page when the user presses ‘a’.
func(event *tcell.EventKey) *tcell.EventKey {
app.SetInputCapture(if event.Rune() == 113 {
app.Stop()else if event.Rune() == 97 {
}
addContactForm()"Add Contact")
pages.SwitchToPage(
}return event
})
And lastly, in our addContactForm
function, we can update our save button to bring us back to the menu after the form is filled out.
"Save", func() {
form.AddButton(append(contacts, contact)
contacts = "Menu")
pages.SwitchToPage( })
Now if you run the code and press a
you should see the form show up.
Take a second to fill it out and then click save. You should be taken back to the main menu and our contact will have been saved to contacts
slice we set up earlier. Only problem now is we can’t see any of our contacts.
What About Q and A?
You may notice that pressing q still quits. Not great if your contact’s name is Quincy Quigley. Don’t worry, we’ll fix this a little bit later.
Lists
Let’s create a List
widget to display our contacts.
var contactsList = tview.NewList().ShowSecondaryText(false)
And then, similar to what we did with our form, we can create a function that adds items to the list.
func addContactList() {
for index, contact := range contacts {
" " + contact.lastName, nil, rune(49+index), nil)
contactsList.AddItem(contact.firstName +
} }
When we add an item to a list we first pass a string. This is the main text that will be displayed. There is also an option for a secondary text that will appear below the main text, but we’ve turned this option off so we can get away with just passing an empty string.
Next, we can pass a rune
which is what will show up next to each item. In this case we want numbers 1..n
, so we can take advantage of the index to do that. (49 is the ascii code for the number 1)
This is a good start, but as we learned earlier, we won’t be able to see our list until we give our app a way to display it. We could add a new page for our list, but we want to be able to display the List
and the TextView
that’s displaying our menu options on the same page.
Flexbox Layout
Tview
gives us a couple options for layouts. The first is a grid system that allows you to set fixed sizes for rows and columns. The second, and the one we’ll use here, is Flexbox. Flex
lets you create layouts by organizing widgets into rows or columns. We can also nest flexboxes
to create more complex layouts.
Start by creating a new flex widget.
var flex = tview.NewFlex()
By default, flexbox
puts widgets next to each other in columns starting from left to right. For example, let’s add three empty boxes to our flex.
true), 0, 1, false).
flex.AddItem(tview.NewBox().SetBorder(true), 0, 1, false).
AddItem(tview.NewBox().SetBorder(true), 0, 1, false) AddItem(tview.NewBox().SetBorder(
We can set the proportion of each box by setting the third argument in AddItem
.
true), 0, 1, false).
flex.AddItem(tview.NewBox().SetBorder(true), 0, 4, false).
AddItem(tview.NewBox().SetBorder(true), 0, 1, false) AddItem(tview.NewBox().SetBorder(
If we don’t want columns, we can tell flex to use rows instead.
flex.SetDirection(tview.FlexRow).true), 0, 1, false).
AddItem(tview.NewBox().SetBorder(true), 0, 4, false).
AddItem(tview.NewBox().SetBorder(true), 0, 1, false) AddItem(tview.NewBox().SetBorder(
For now we just want to put our List
on top of our TextView
, which means we could use a single Flex
with rows. But later we’ll want to add another widget next to our List
, which will require nesting a Flex
with columns into a Flex
with rows.
flex.SetDirection(tview.FlexRow).
AddItem(tview.NewFlex().0, 1, true).
AddItem(contactsList, 0, 1, false) AddItem(text,
Let’s also update our SetInputCapture
function. We can actually call this function on any tview
widget. The function only gets called if the widget we attach it to has focus. Since the app
is the parent of all widgets, it always has focus. By moving the SetInputCapture
off of the app
and onto the flexbox
we are ensuring that when our form page has focus, users can use the q
and a
buttons without triggering a quit or new form action.
func(event *tcell.EventKey) *tcell.EventKey {
flex.SetInputCapture(if event.Rune() == 113 {
app.Stop()else if event.Rune() == 97 {
}
addContactForm()"Add Contact")
pages.SwitchToPage(
}return event
})
Now, if you run the code and use the form to add a contact, you should be brought back to the menu and see a list displaying the first and last name of the contact you just added.
Add a few more contacts and you’ll notice we have two bugs. Each time we go back to the form page, an additional form gets added.
Also, when we get brought back to our contacts list, we see that names are being repeated.
Refreshing
Both of these issues have the same cause. Each time we switch to a page we are adding on to the existing page rather than recreating the page with the new updates. We can fix this pretty easily by clearing the widgets each time we change pages.
else if event.Rune() == 97 {
} true)
form.Clear(
addContactForm()"Add Contact")
pages.SwitchToPage( }
func addContactList() {
contactsList.Clear()for index, contact := range contacts {
" "+contact.lastName, " ", rune(49+index), nil)
contactsList.AddItem(contact.firstName+
} }
Now you should be able to add multiple contacts without seeing duplicates and without doubling up on forms.
Showing Contact Info
When we select a contact from the list we want to be able to view their information. We’ll display it in a text box to the right of our list. First, let’s add the new text box.
var contactText = tview.NewTextView()
We can tell our list to run a function each time an item on it is selected.
func(index int, name string, second_name string, shortcut rune) {
contactsList.SetSelectedFunc(
setConcatText(&contacts[index]) })
We’ll use it to pass the selected contact to a new function that sets the text of our contactText
TextView
.
func setConcatText(contact *Contact) {
contactText.Clear()" " + contact.lastName + "\n" + contact.email + "\n" + contact.phoneNumber
text := contact.firstName +
contactText.SetText(text) }
We use the same process here of first clearing the widget and then rewriting the content.
The last step is adding our new textbox to the layout.
flex.SetDirection(tview.FlexRow).
AddItem(tview.NewFlex().0, 1, true).
AddItem(contactsList, 0, 4, false), 0, 6, false).
AddItem(contactText, 0, 1, false) AddItem(text,
Now if we run our code, we can add contacts. Then when we select them from the list we can see the details appear to the right.
Conclusion
We’ve barely scratched the surface of what’s possible with Tview
. There’s a lot more we could do with styles and layouts. One big problem with our app is that it’s not persisting any data. For something simple like this we could get away with writing to a csv file, but for larger apps, you might want to checkout this tview
example with postgres.
TUIs will never be able to compare to a full Graphical User Interface, but for certain applications they can provide more options than you might think. They’re particularly great for applications developers use frequently since not having to leave the terminal can be a plus, and sometimes you might be on a server that doesn’t have access to a GUI at all.
I’m definitely sold on them and will be looking for more opportunities to build them in the future. If you know of any cool TUI apps or libraries, please let me know.
Also, if you’re the type of person who’s liked building a TUI in Go then you might like Earthly:
Earthly makes CI/CD super simple
Fast, repeatable CI/CD with an instantly familiar syntax – like Dockerfile and Makefile had a baby.