Adventures in Go: Writing a TUI, Part 1 - tview
I've been coding in Go quite often for the last new months, and it has certainly become my favorite programming language. So far I've made a small music tag editor, a variation of a hangman game, a little music scheduler, and was trying to build a dotfile manager but that didn't go too far.
And in the process I've actually learned a lot about making TUIs (Terminual User Interfaces), which prompted me to write this article. I plan this to be a series in which I'll try different TUI packages, build a small program with each, and give my thoughts on the wonders and limitations of each.
Today we'll begin with the first one I've used: tview
. From its Github page:
This Go package provides commonly needed components for terminal based user interfaces.
Among these components are:
* Input forms (include input/password fields, drop-down selections, checkboxes, and buttons)
* Navigable multi-color text views
* Sophisticated navigable table views
* Flexible tree views
* Selectable lists
* Grid, Flexbox and page layouts
* Modal message windows
* An application wrapper
They come with lots of customization options and can be easily extended to fit your needs.
It's based on the excellent and thorough tcell
, and it's certainly customizable - I've used it to build Index's interface:
The second screenshot goes on to show how flexible this package is, as there are multiple widgets packed together there and managing each is interesting, but we'‘ll get there later.
Basics
Now, let's take a quick look at how a typical TUI written with tview
looks like:
package main
import "github.com/rivo/tview"
func main() {
var (
// An appplication is the object in charge of containing all the
// widgets, and controlling the drawing
app = tview.NewApplication()
// A very simple widget, just a button
button = tview.NewButton("Button")
)
// Set some parameters, like showing the border and the position of
// the widget, and you can chain these together
button.SetBorder(true).SetRect(20, 10, 25, 5)
// Make the button the main widget, and don't put it on fullscreen
app.SetRoot(button, false)
// Make the button be selected when starting the program
app.SetFocus(button)
// Run it!
err := app.Run()
if err != nil {
panic(err)
}
}
Try to run it and you should get this:
Looks great! Now let's see how we can add functionality to the button - for example, make it close the program when we hit Enter
.
Connecting widgets to actions
If you've worked with GTK, you'll be familiar with the concept of signals, and since Go has great support for first-class functions, we can do something similar in tview
. Most widgets have a SetSelectedFunc
method that will take a function as a parameter and execute it each time you select it. Such function can be defined properly or we can create an anonymous function right in the spot:
It's based on the excellent and thorough tcell, and it's certainly customizable - I've used it to build Index's interface:
button.SetSelectedFunc(func() {
app.Stop()
})
Notice that since this is a closure, it has access to variables from the block in which it was created. If we were to put this functionality inside a separate function, we'd need to make app
a global variable.
Keybindings
We can customize the bindings for either the whole application or a specific widget, but keep in mind that we cannot override the ones we set in the former with new ones in the latter, nor those that come as default in a more complex widget, like Form.
Usually, tview
stops the application when hitting Ctrl-C
, but let's say we also want to assign Esc
to that. For such, we have to import tcell
, which will help us handle events from the keyboard, and call the application's SetInputCapture
method with a function that receives these events, and acts on them.
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
app.Stop()
}
return event
})
You can also check for individual letters:
switch event.Rune() {
case 'j':
foo()
case 'h', 'l':
bar()
}
For more information about the key events accepted by tcell
, I recommend checking the documentation.
Building something
For the mere purposes of this tutorial, I ended up building a small clone of ranger that only moves between directories, but has colors, and a similar UI. Notice that it's not really usable and only acts as a way to showcase the posibilities of tview
. You can also find it in a repo here.
package main
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"time"
"github.com/dustin/go-humanize"
"github.com/gdamore/tcell"
"github.com/rivo/tview"
)
var (
// Global channel to send the current directory to
cwdch = make(chan string)
// Global channel to send info about the current file, and
// general status updates. Has to be buffered else the program
// enters a deadlock for some reason
statusch = make(chan string, 1)
)
func main() {
var (
// Main application
app = tview.NewApplication()
// Using a Grid layout allows us to put many primitives next to
// each other and have them respond to the terminal size
mainview = tview.NewGrid()
// Show the files and directories inside the current, previous,
// and next directory panels
previousdir = tview.NewTable()
currentdir = tview.NewTable()
nextdir = tview.NewTable()
// Small title showing the whole directory name
cwd = tview.NewTextView()
// Simple status bar showing current file permissions and other info
fileinfo = tview.NewTextView()
)
// Init widgets
initPanels(previousdir, currentdir, nextdir, app)
initDirTitle(cwd, app)
initFileInfo(fileinfo, app)
// Set our mainview to resemble ranger
mainview.
SetBorders(true).
SetColumns(10, 0, 40).
SetRows(1, 0, 1)
// Add the widgets
mainview.
AddItem(cwd, 0, 0, 1, 3, 1, 1, false).
AddItem(previousdir, 1, 0, 1, 1, 1, 1, false).
AddItem(currentdir, 1, 1, 1, 1, 1, 1, false).
AddItem(nextdir, 1, 2, 1, 1, 1, 1, false).
AddItem(fileinfo, 2, 0, 1, 3, 1, 1, true)
// Make the mainview the root widget, and fullscreen it
app.SetRoot(mainview, true)
// Focus on the directory we are in
app.SetFocus(currentdir)
// Run the application
err := app.Run()
if err != nil {
panic(err)
}
}
// Preparation to properly initialize each panel upon start
func initPanels(previousdir, currentdir, nextdir *tview.Table, app *tview.Application) {
// Make each selectable
previousdir.SetSelectable(true, false)
currentdir.SetSelectable(true, false)
nextdir.SetSelectable(true, false)
// Get the files and dirs of the current and previous directory
getDirContents(previousdir, "..")
getDirContents(currentdir, ".")
// Define the function to be called each time we move to a new file or dir
preview := func(row, column int) {
currentselect := currentdir.GetCell(row, 0).Text
previewDir(currentselect, nextdir)
}
// Use it on the first item
preview(0, 0)
// Install it
currentdir.SetSelectionChangedFunc(preview)
// Moving between dirs, and exiting
currentdir.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'h':
// Move up
err := os.Chdir("..")
printError(err)
copyDirToPanel(currentdir, nextdir)
copyDirToPanel(previousdir, currentdir)
getDirContents(previousdir, "..")
// Update directory bar
printCwd()
case 'l':
// Move into a dir
r, _ := currentdir.GetSelection()
currentItem := currentdir.GetCell(r, 0).Text
// Get info about the current item
fi, err := os.Stat(currentItem)
printError(err)
if fi.IsDir() {
err = os.Chdir(currentItem)
printError(err)
copyDirToPanel(currentdir, previousdir)
copyDirToPanel(nextdir, currentdir)
preview(0, 0)
}
// Update directory bar
printCwd()
case 'q':
app.Stop()
}
return event
})
}
func initFileInfo(fi *tview.TextView, app *tview.Application) {
// Display colors given by text
fi.SetDynamicColors(true)
// Constantly check and print file permissions etc
go func() {
var info string
for {
info = <-statusch
fi.SetText(info)
app.Draw()
}
}()
}
func initDirTitle(cwd *tview.TextView, app *tview.Application) {
// Make the title blue and accept colors
cwd.SetDynamicColors(true)
cwd.SetTextColor(tcell.ColorBlue)
// Get initial dir
wd, err := os.Getwd()
printError(err)
// Check and print info about the current dir
go func() {
var dir string
for {
dir = <-cwdch
cwd.SetText("[::b]" + filepath.Dir(dir) + "/[white]" + filepath.Base(dir))
app.Draw()
}
}()
cwdch <- wd
}
func copyDirToPanel(previousdir, currentdir *tview.Table) {
// Make sure there are no elements in the panel
currentdir.Clear()
for i := 0; i < previousdir.GetRowCount(); i++ {
currentdir.SetCell(i, 0, previousdir.GetCell(i, 0))
}
}
func previewDir(path string, paneldir *tview.Table) {
// Clean the panel
paneldir.Clear()
// Get info about the given path
fi, err := os.Stat(path)
if err != nil {
return
}
if fi.IsDir() {
getDirContents(paneldir, path)
}
// Send info about the current file
statusch <- fmt.Sprintf("[fuchsia]%s[white]\t%s\t%s",
fi.Mode().String(),
fi.ModTime().Round(time.Second).String(),
humanize.Bytes(uint64(fi.Size())))
}
func getDirContents(dirtable *tview.Table, path string) {
// Make sure to the table is empty
dirtable.Clear()
// Get the contents of the directory
files, err := ioutil.ReadDir(path)
if err != nil {
return
}
for i, f := range files {
var color = tcell.ColorWhite
// Create a new cell for the table, and make sure it takes up
// all the horizontal space
cell := tview.NewTableCell(f.Name())
cell.SetExpansion(1)
// Colorize according to extension and mode
if f.IsDir() {
color = tcell.ColorNavy
cell.SetAttributes(tcell.AttrBold)
}
switch filepath.Ext(f.Name()) {
case ".png", ".jpg":
color = tcell.ColorYellow
case ".mp4", ".mkv", ".avi", ".webm", ".mp3", ".m4a", ".flac":
color = tcell.ColorFuchsia
case ".zip", ".gz", ".iso":
color = tcell.ColorRed
}
if f.Mode()&os.ModeSymlink != 0 {
color = tcell.ColorTeal
}
cell.SetTextColor(color)
// Add it to the table on the i row, first column
dirtable.SetCell(i, 0, cell)
}
}
func printCwd() {
dir, err := os.Getwd()
printError(err)
cwdch <- dir
}
func printError(err error) {
if err != nil {
statusch <- fmt.Sprintf("[red]%s", err)
}
}
The code is mostly simple, but the whole navigation logic is imperfect and riddled with edge cases. It'd be interesting to completely rewrite it to generate a new Table
each time we move between dirs and just traverse that, pretty much building a tree, so cursor positions remain where they were. It could also implement swapping the right panel with a TextView
that shows the output of file
if the current item is a binary, or printing part of the file if it's text (might also be able to parse if it's code, and use a syntax highlight package), etc.
Building a featureful, thorough file manager is a complex task, and although there are plenty around (mc
, lf
, nnn
, fff
, ranger
, vimfm
, etc.), it should be a challenging and fun experience.