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:

index
Manual Tagging mode
index
Auto Tagging mode

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:

index

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.

index
Amazing what you can achieve in just a few lines of code
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.