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:
- Navigate to the blog working directory
- Create the new post using
hugo new blog/title.md
- Edit/write the post with
vim
- Change the header data to
draft = false
- Generate the site files with
hugo
- 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.