Producing propaganda takes priority for prospective pernicious persons. As a supervillian, it is important to be able to reach your audience and propagate your ideas. This blog is my version of that and for the last while I have been using the static site generator Hugo after migrating from Wordpress (see this post).

My typical workflow for writing a new post is as follows:

  1. Navigate to the blog working directory
  2. Create the new post using hugo new blog/title.md
  3. Edit/write the post with vim
  4. Change the header data to draft = false
  5. Generate the site files with hugo
  6. Publish the files to Github pages with git

This workflow has been great, but it also has a few drawbacks. Chief of which is there is not really a nice overview of the posts on the site and the management of the posts is a bit tedious1. Hugo has the hugo list subcommand, but the options are: all, drafts, expired, and future. Selecting all dumps detailed CSV data of all the posts:

path,slug,title,date,expiryDate,publishDate,draft,permalink
content/blog/devlog-pneuma.md,,Devlog:
Pneuma,2021-01-22T09:40:42+02:00,0001-01-01T00:00:00Z,2021-01-22T09:40:42+02:00,true,http://sudosays.github.io/blog/devlog-pneuma/
...

The above output is not really human-friendly at a glance. Meanwhile, hugo list drafts only lists the paths of the files marked as drafts.

Finally, deletion of posts is done manually, and publishing a draft requires opening the file to edit the header. In terms of CRUD, it is a very disjointed process.

To address this slight inconvenience I had an idea to write an interactive frontend for Hugo that will let me perform some basic management in a simple and straightforward way. It was not until I started writing a lot more Go and had a bit of free time on my hands that pneuma was born.

Below I detail some interesting challenges I have had to solve so far.

Designing the TUI

Executing the Hugo commands and parsing their input is straightforward enough with the os/exec package, but what I wanted was an interactive user interface. So I had a look at some TUI based programs I enjoy, particularly aerc by Drew Devault and I saw that he made use of the tcell package by Garrett D’Amore.

tcell provides a very bare-bones approach to TUI design. Central to the package is the idea of a Screen where you can SetContent one character at a time and then PollEvent to wait for an event. This means that tcell promotes an event-driven approach to creating a TUI2.

What followed was a tight iteration loop, gradually building out a UI system. Each iteration built upon the last and the API would abstract away from lower level functions. This resulted in the following data structure for a UI:

type cursor struct {
	X, Y int
}

type PneumaUI struct {
	Screen      tcell.Screen
	Cursor      cursor
	Exit        bool
	Style       tcell.Style
	Mode        Mode
}

The most basic functionality is achieved with two functions: putRune and MoveCursor:

func (ui *PneumaUI) MoveCursor(x, y int) error {
	w, h := ui.Screen.Size()
	if x < 0 || x > w || y < 0 || y > h {
		return errors.New("cursor out of bounds")
	}

	ui.Cursor.X = x
	ui.Cursor.Y = y
	return nil
}

func (ui PneumaUI) putRune(r rune) {
	ui.Screen.SetContent(ui.Cursor.X, ui.Cursor.Y, r, []rune{}, ui.Style)
}

Building on only these two functions, emerged more sophisticated behaviours such as putStr, hLine, vLine. From those, even more such as drawing boxes. However, as our UI became more sophisticated, drawing and managing the content on the screen became more complex. It was serviceable, but tedious.

For example, drawing a UI with some text labels would be as follows:

ui.MoveCursor(0,0)
ui.PutStr("A string of text")
ui.MoveCursor(0, 1)
ui.PutStr("Another string")
aList := []string{"some", "list", "items"}
ui.MoveCursor(0,2)
for i, item in range aList {
    ui.MoveCursor(0,2+i)
    ui.PutStr(fmt.Sprintf("%d: %s", i, item))
}

To simplify things, I wanted to have all of the content on screen added to the UI with a widget approach. The content of the screen can be rendered with a single call to ui.Draw and each widget would handle its own draw logic.

In Go, we can achieve this by having a common interface which we will call Drawable. This way we can specify a shared function signature and group structs that share the interface together. Now, our UI can contain a slice of Drawable items that we iterate through and call their individual Draw methods on.

For eaxmple, a Label widget is defined as follows:

type Drawable interface {
	Draw(ui *PneumaUI)
}

type Label struct {
	X, Y    int
	Content string
}

func (l Label) Draw(ui *PneumaUI) {
	ui.MoveCursor(l.X, l.Y)
	ui.putString(l.Content)
}

Now all we need to do to draw our entire UI is the following:

func (ui *PneumaUI) Draw() {
	for _, drawable := range ui.Content {
		drawable.Draw(ui)
	}
}

This means that we can add/remove widgets to our UI on demand and redraw the UI as needed. Also, if we want to add different widget types, they just need to implement the Drawable interface.

Interactivity and Callbacks

The TUI is event-driven. It blocks on tcell.Screen.PollEvent and so pneuma spins on this. I have taken inspiration from a few TUI programs and developed a ui.Tick() function that the main program repeatedly calls while waiting for an event. In this function, we synchronise the screen contents, update any state information of widgets, and then poll for events.

At first, I hardcoded commands and their behaviour into the event polling, but this proved really inflexible. It also tightly coupled the UI logic with the program logic which started forcing more and more spaghetti code into the project to cover different contexts and use cases.

What I wanted was a way to dynamically set the commands that can be executed instead of manually inputting the logic for each specific situation. While the project is probably small enough that I could have gotten away without it, it still presented an interesting problem.

My solution? Callback functions.

The approach is simple: we link certain key events to functions that we want called. Then when the UI polls for events, it checks if any of the key events in the commands list has happened and then call the appropriate function.

For my implementation I made use of Go’s map data structure which has a lookup time of O(1). The end result looks something like this:

type CommandKey struct {
	Key  tcell.Key
	Rune rune
	Mod  tcell.ModMask
}

type PneumaUI struct {
    // ...
	Commands    map[CommandKey]func()
}

The CommandKey struct is used to compare the tcell.EventKey while ignoring the unnecessary information.

Using this approach the event polling in the TUI is much, much simpler while also being dynamically configurable.

func (ui *PneumaUI) Tick() {
	switch ev := ui.Screen.PollEvent().(type) {
	case *tcell.EventKey:
        cmd := CommandKey{Key: ev.Key(), Rune: ev.Rune(), Mod: ev.Modifiers()}
        if callback, ok := ui.Commands[cmd]; ok {
            callback() // Execute the callback function
            ui.Redraw()
        }
    }
}

This means that in our main program we can dynamically configure the commands as needed, passing the callback functions we want.

editPost := func() {
    path := posts[table.Index].Path
    startEditor(path)
}

quitCmdEventKey := libui.CommandKey{Key: tcell.KeyRune, Rune: 'q', Mod: tcell.ModNone}

cmds := make(map[libui.CommandKey]func())
cmds[quitCmdEventKey] = quit
cmds[enterCmdEventKey] = editPost

ui.SetCommands(cmds)

Once the commands are set, we simply loop and tick the UI.

for {
    ui.Tick()
}

It is interesting to note that we make use of an anonymous function when we specify editPost. This is because, while we want to call startEditor we also want to do some work beforehand. Ultimately, we can start to compose more and more complicated functionality that is passed as a single command.

And that’s all for the interactivity!

Issue: Input Capture and Control

One of the persistent issues I am currently facing is when I want to edit files. Currently, my code is as follows:

func startEditor(path string) {
	editorCmd := exec.Command("vim", path)
	editorCmd.Stdin, editorCmd.Stdout, editorCmd.Stderr = os.Stdin, os.Stdout, os.Stderr
	err := editorCmd.Start()
	err = editorCmd.Wait()
	check(err)
}

With this, I hand over the os.Std(in/out/err) files to vim, but I am finding that vim is lagging heavily. On top of that, it seems that input is also being captured by pneuma (such as q) and is quitting the program, while vim is running. It appears to be an issue in golang itself when forking processes and trying to run them in the foreground. It might also be that tcell is setting the terminal into a strange state that vim is then trying to correct from.

Regardless, it presents an interesting an novel problem to me.

If you’re interested in hacking on pneuma, you are more than welcome to. I am not going to be accepting PRs at this time, but feel free to create issues and comments. You can find the repo at https://github.com/sudosays/pneuma/ or check out the project page here.


  1. As of Hugo version 0.80.0 ↩︎

  2. From the tcell documentation regarding PollEvent: “Main application loops must spin on this to prevent the application from stalling.” ↩︎