package of the moment: tview and tcell
I wanted to make an interactive terminal interface for something. Usually I just bang out some vt100 escapes to move the cursor around, color this, erase that. It’s crude but effective as long as the number of screen elements is kept to a minimum. This time, though, I decided on a slightly more disciplined approach, and so I was looking for a library that might assist in drawing views of various sizes, and input fields, and buttons. The works. In go.
After an exhausting search, I settled on rivo/tview. It seems to do everything I could want. There’s an animated gif in the readme that I found almost hard to believe. Good way to sell it, and the program that’s demoed is included. tview depends on gdamore/tcell and some of that is exposed to the user, so I’ll cover that too.
It’s well put together and easy to use. Took me a bit to get started, for which the demos, especially the all inclusive presentation demo, were quite helpful. Once I got off the ground, most things fit together pretty easily. I want a text view, there’s a TextView. I want a table, there’s a Table.
Most of the widgets have default input behaviors that make sense. Arrow keys work, but you can also navigate a table using hjkl. ^C is handled sensibly, quit the app and restore the screen. It’s easy to override or add extra behaviors, too. Want to move between form elements with arrow keys instead of just tab? Add a handler that converts one key to another. This is where tcell is exposed to the user. Key events are all straight from tcell. The set of callbacks you can set seems just about right. You can get called whenever the user picks a table entry (using enter) or whenever the selection changes (using arrow keys). Add buttons to a form, only get called when they save it. For the most part, I just wrote a few functions to update application state when things changed, a few hooks to navigate between modes, and otherwise let tview drive itself.
There are a few caveats and surprises. Pretty much everything that takes text parses it for inline color codes specified with square brackets. I was not expecting this. Was wondering why all the
[Update] email subjects were getting mangled. There is an escape function, but no general way to turn it off entirely.
There’s a concept of focus and blur, but only a few elements change their appearance when focus. Buttons, for instance. But there’s no automatic way to invert a frame title when it’s focused. For that matter, I couldn’t figure out how to do so manually. Instead, I build frames by making a grid with a one line text area which I can change the style on. I ran into a quirk with how forms pick which input to focus. Actually, I could categorize all the quirks I experienced as trying to change things after the fact. It seems tview would much prefer that you rebuild frames and forms every time you display them. Not sure if I was just misusing it. I was trying to reuse an existing form by resetting all the inputs, but it’s hard to get back to a blank state that way. Clearing and rebuilding seems the right way.
That said, I got good results in a very short time. Much better results than if I’d been building up from scratch.
A few notes on vendoring. The aforementioned gif is checked into the source repo. You can shave a few megabytes by chopping that out.
tcell takes an interesting approach to the terminfo problem. A bunch of common terminal types are built into the library in the form of go files. I’m not sure what definition of common was used, though, since it includes stuff like Data General and Wyse terminals not seen in the wild for several decades. I think I’d include a few definitions for screen and xterm and a few others and then just fallback to some baseline vt100 ansi subset. In addition to the builtin terminals, there’s a giant database.json file, although tcell won’t actually read it out of the source directory. So there’s another file you can probably leave behind.
tview mostly relies on tcell to do the work of drawing efficiently. tcell does this by more or less double buffering the screen. If a screen cell changes, tcell will write it to the terminal. Otherwise it skips cells that haven’t changed. In theory, this minimizes writes if the screen doesn’t change a lot. In reality, though, it writes out every changed cell with a separate write call, resulting in a ton of tiny writes.
Consider a 80x50 terminal looking at a list of emails. We move the selection down one row. tview repaints the screen. tcell notices that two rows have changed. The previously selected row must be redrawn without highlighting. The new row must redrawn with highlighting added. So we expect about 160 bytes to be written, plus a little overhead for the control characters. (A full screen redraw would be about 4000 bytes, so we expect better than that.) And indeed that’s what we see in ktrace. 160 bytes. Written o n e b y t e a t a t i m e. Each cell is written out individually to an unbuffered file.
This was easy to fix. A bufio can be wrapped around the output file. Collect all the writes, flush once. All 160 bytes in a single system call. ktrace approved.
The next problem is that there’s no support for scrolling. Many terminals, going way back when, support hardware scrolling. You specify a region of the screen, in rows, to scroll (to exclude menus and status bars). Then a short escape sequence will scroll the region up a line (or several). Then move the cursor to the bottom of the region and write a new row. So for instance, if we’re looking at our list of emails, we scroll the list up a line, and print a new line at the bottom. This requires writing 80 plus a few bytes, vs 4000. This is one of the things that makes a well written terminal app perform so well even over a slow network connection.
Except that’s not how tcell does things. It doesn’t expose a scroll operation, so tview doesn’t use it, even though the tview table would be a perfect fit. Instead, tview redraws the screen, damaging every cell, and then tcell writes all 4000 bytes out to the terminal. (One byte a time, too. 4000 write calls.) Scrolling through a list of 200 emails on a 80x50 screen should require a total transfer of somewhere on the order of 80x200 16000 bytes. But repainting after every scroll is more like 80x50x150 600000. The bigger your terminal, the worse it gets.
Fixing this is more complex. tcell would need to do something like triple buffering. After a repaint, we compare screens to see if most of the changes are actually a scroll. Scroll the terminal to efficiently update what we can, then redraw just the parts that are still different. Hardware scrolling only works on a line basis, so it would be incompatible with some tview features like a vertically split screen. But with a bit of smarts, we could still scroll the whole row, and only redraw one half intead of a full repaint.
On the other end of our pseudo terminal, there’s probably a graphical terminal emulator of some sort. It would probably appreciate batching writes and hardware scrolling. One byte writes on one end result in one byte reads on the other end, so now two programs are operating inefficiently. And it can be faster to redraw an existing image/texture at a new offset vs blitting in a whole new buffer.
Otherwise, everything works great. It’s a lot of work getting terminal app to resize and draw any sort of decoration where you want it. Using tview saved me tons of effort.