Building TUIs with Go
Code for this project lives here
I recently started building out a CLI application for task management using Go. I wanted this application to have an easilly navigable TUI with easy to remember keybinds. In this article I will go over the features of the tool and my development experience so far; covering things like dependencies and roadblocks I ran into.
Desired Features
- A todo list which can be added to, edited, reordered, and searched.
- A Kanban-esque workflow with different categories for stages of task completion.
- Easilly available help screen providing a list of available keybinds per focused "view".
Dependencies
For this project I wanted to keep the list of dependencies small. I also did not want to include any frameworks that provide too much "magic"; that is, obfuscation of the underlying implementation.
Now, when I first started searching for libraries to help me build out this project, the first, and by far most popular, one I came accross was bubbletea. This was intriguing for me because Bubbletea claimed to be working off of the Elm(https://elm-lang.org/) architecture. I had used Elm in the past and I thought it was a very good approach to UI design.
Unfortunately, after trying to build a proof of concept with Bubbletea, I was a bit disappointed. Bubbletea is really quite good, but I found that the Elm model doesn't translate as well to Go as I would have hoped. This makes sense as Elm was a language written to be used for this design pattern and Go is very different language. In addition to this issue, Bubbletea's documentation was lacking and did not provide great examples for handling user input. They have a vast array of pre-built components that can be plugged in and used as-is, but I needed more control of the appearance and behavior of these compontents than was offered.
Enter Tcell, a very unopinionated and barebones library for drawing UIs in the terminal. After a quick proof of concept, Tcell is what I decided to go with as my core dependency. Tcell is simple, concise, includes everything I want, and nothing I don't. Here's an example of some code using Tcell from my project:
func (v *View) Draw(screen tcell.Screen) {
x1, y1, _, _ := v.getInnerBounds()
bx1, by1, bx2, by2 := v.getOuterBounds()
// Draw fillColor
fillStyle := tcell.StyleDefault.Background(v.fillColor)
for yidx := by1; yidx <= by2; yidx++ {
for xidx := bx1; xidx <= bx2; xidx++ {
screen.SetContent(xidx, yidx, ' ', nil, fillStyle)
}
}
if v.border && v.h > 2 {
screen.SetContent(bx1, by1, tcell.RuneULCorner, nil, fillStyle)
screen.SetContent(bx2, by1, tcell.RuneURCorner, nil, fillStyle)
screen.SetContent(bx1, by2, tcell.RuneLLCorner, nil, fillStyle)
screen.SetContent(bx2, by2, tcell.RuneLRCorner, nil, fillStyle)
for xidx := bx1 + 1; xidx < bx2; xidx++ {
screen.SetContent(xidx, by1, tcell.RuneHLine, nil, fillStyle)
screen.SetContent(xidx, by2, tcell.RuneHLine, nil, fillStyle)
}
for yidx := by1 + 1; yidx < by2; yidx++ {
screen.SetContent(bx1, yidx, tcell.RuneVLine, nil, fillStyle)
screen.SetContent(bx2, yidx, tcell.RuneVLine, nil, fillStyle)
}
}
for _, cell := range v.cells {
x := x1 + cell.x
y := y1 + cell.y
screen.SetContent(x, y, cell.char, nil, cell.style)
}
}
This is the function that draws a "view". We'll get into what that is later.
func (v *View) handleEvent(ev tcell.Event) {
switch ev := ev.(type) {
case *tcell.EventKey:
var key tcell.Key
if ev.Key() == tcell.KeyRune {
if v.Mode == InputMode {
v.handleInputRune(ev.Rune())
return
}
key = tcell.Key(ev.Rune())
} else {
key = ev.Key()
}
kb, err := v.getKeybind(v.Mode, key)
if err == nil {
kb.callback(v)
}
}
}
And this is an example of handling events with Tcell.
I liked the simplicity of the Tcell library as it would allow me to develop my own architecture that would be optimized for my use-case while also handling some of the tough and tedious stuff like mapping escape sequences for special keybinds.
Implementation
I started out by building the core todo list feature. I did this in as simple and naive a manner as possible, using global draw functions, and keeping UI and application state in the same global struct. I handled events globally as well and modified behavior via setting "Modes". As an example, in input mode, we would need to write runes to an internal buffer, but in normal mode, runes would just be normal keybinds.
After writing about 1000 lines of code, I started noticing patterns of repeated code. I abstracted these patterns out into utility functions. This was mostly for things like writing text at specific corrdinates, drawing boxes with borders and backgrounds, binding keys, etc.
It was at this point I started getting some ideas about how I might turn my code into a library for building TUIs a-la the afforementioned Bubbletea.
Gotui
I decied to name my new library Gotui. Simple enough, it's Go, and it's for makeing TUIs.
I have a lot of experience with a component based javascript UI framework called Vue.
I enjoy Vue's component model where data flows downward and events flow upward. In Vue,
Components can be nested to arbitrary depth and communicate with their parents and
children via props
and events
. I thought I could probably implement something similar
for my TUI library.
I started out by moving all of the afforementioned rendering utility functions our into a separate module. I then decided that application state should be handled by the application and that my library should not conern itself with that at all. The purpose of gotui is to make rendering and event handling less tedious and more intuitive.
The biggest pain point I wanted to solve was scoping events. With Tcell, all events are globally scoped, so are all draw calls. This is a fine way of doing things, but when you want to implement different behavior in different contexts, it can get messy quickly.
I solved these issues by creating a 2-layer architecture of App
and View
. App
is
a global struct that can have many views
as children. The views can then be "focused"
and only keybinds attached to the focused view will fire.
The next pain point was figuring out where to start drawing content so that it was "inside"
of a specific UI component. What I mean by this is that in order to draw text in the
correct location, you need to know the exact x and y coordinates of the cells relative
to the entire screen. I wanted a system where you could call SetContent
on a View
and
you would only need to provide coordinates relative to the View's internal content
area, without worrying about where the component is on screen and how far to offset
based on surrounding components.
I solved this issue by giving the view it's own draw method, which you can see in the code example above. Essentially, the view has its own internal cell buffer and knows how to draw itself and internals relative to itself. The developer still needs to specify the origin point and dimensions of a view realtive to the screen, but setting the content was significantly more complex than drawing the container, so this is a non-issue.
Active Development
Gotui isn't actually a standalone package at the moment; instead, it's an internal module in the gettuit application. This will be the case until I'm satisfied that gotui can meet all of the base requirements to make it a usable library.
I figured developing a real-world application using the library would allow real-world concerns to drive the development of the library. Thus far, that assumption has been correct.
The plan is to continue developing gettuit and gotui in tandem until I find myself not needing to make changes to gotui in order to implement features for gettuit. At that point I will break gotui out into a standalone library and make it available for anyone to use. The bonus with this approach is that I already have a portion of the documentation completed as gettuit can serve as a great example of how to use gotui in an idiomatic way.
Open Source
I'm having a ton of fun building this project and have licensed it under the MIT license. Everything I'm doing here is 100% free and open. I am definitely looking for people who want to contribute to the project by either working on the project itself, writing documantation, or using it to build their own applications.
Please, shoot me a pull request: https://github.com/FFX01/gettuit