Building The Worst Vi Emulation for My Mail Client

Tags:

Why, What and How exactly

I wasnt able to grasp mutt in 25 seconds and decided I have to write my own alternative terminal mail client, its name is: postbote.

A screenshot:

postbote screenshot main page

It does way less, is a work in progress but its mine and therefore I need vi style motions. I also need:

  • a single, opinionated configuration

  • a go template based scripting and commands system

  • IMAP, SMTP support via ProtonMail Bridge or any standard server

  • most MIME types supported by default

    • images are rendered to the terminal
    • text plain is simply rendered
    • html is converted to text
    • others configurable in postbote.toml:
    TOML
    1# execute commands when an attachment with a matching mime type 
    2# is opened via <id>gf
    3[MIME]
    4"application/pdf" = "zathura --fork {{file.path}}"
    5"text/plain"      = "nvim -R {{file.path}}"

Motions (my definition)

I’m aware this isn’t fully what vi motions do, simply because they are way more powerful. The goal is to implement a subset of this composability so I have my muscle memory while looking at and writing my emails.

I define a motion as a combination of a modifier and a command. Since I wont add visual selections or other ranges I decided to go with the modifier being a number. And the command being a string. For instance typing 12gf will open the file with the id 12.

GO
 1// Vi style motion emulation.
 2//
 3//	count command
 4//
 5// Where
 6//
 7//	count := \d*
 8//	command := [hjklgGqfx]+
 9//
10// Enables behaviour like 25j for moving 25 mails down, 
11// 2gf for going to the second attachment and gx for 
12// opening an url or file path via the operating
13// systems open behaviour
14type vi struct {
15	modifier uint
16	command  strings.Builder
17}

Valid Commands, Prefixes and Chars

Recognising an input as valid, requires a list of valid commands:

GO
 1var validCommandList = []string{
 2	"h",
 3	"j",
 4	"k",
 5	"l",
 6	"G",
 7	"gg",
 8	"gf",
 9	"gx",
10	"q",
11	"/",
12	"a",
13}

Only accepting chars included in the above list and only accepting a combination thats a valid prefix requires me to keep three lookup tables filled at compilation unit runtime init:

GO
 1var validCommands = map[string]struct{}{}
 2var validRunes = map[rune]struct{}{}
 3var validPrefixes = map[string]struct{}{}
 4
 5func init() {
 6	for _, cmd := range validCommandList {
 7		validCommands[cmd] = struct{}{}
 8		for _, r := range cmd {
 9			validRunes[r] = struct{}{}
10		}
11		// this is currently the best way i can think of,
12        // besides a trie, which i dont think is 
13        // necessary for a whole 11 commands
14		for i := 1; i <= len(cmd); i++ {
15			validPrefixes[cmd[:i]] = struct{}{}
16		}
17	}
18}

validPrefixes could be a trie, but thusfar postbote only has 11 commands. Ill get to it, once it hits the fan and is too slow.

Interacting with bubbletea

Since I use bubbletea with bubbles and lipgloss, I want to use the built in way of dealing with inputs bubbletea provides. For this the vi emulation state is attached to the bubbletea model.

GO
1type model struct {
2	vi        vi
3    // [...]
4}

Since the keyboard handling is in Update (satistfying tea.Model requires implementing Init, Update and View) type switching on the tea.Msg gives me the tea.KeyMsg holding the state for a key input.

We pass the msg to vi.update and if the vi state signals us there is a complete motion recognised (some), we act on msg.command, for instance if q was recognised:

GO
 1func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 2	switch typed := msg.(type) {
 3	case tea.WindowSizeMsg:
 4    // [...]
 5    case tea.KeyMsg:
 6		if msg, some := m.vi.update(typed); some {
 7			switch msg.command {
 8			case "q":
 9				return m, tea.Quit
10			}
11		}
12	}
13
14	return m, nil
15}

Vi’ing all over the place

So this is the heart of the whole thing. Its a small state machine working as follows:

  1. if esc reset state and exit
  2. if not single rune exit (rune is a go char, see Code points, characters, and runes)
  3. if numeric rune, update the modifier
  4. if a valid alhabetic rune, write it to the command buffer
  5. if the current string repr of the buffer (cmd) is a valid command, produce a viMessage, reset vi state and return the msg
  6. if cmd is not a valid prefix for a command, reset vi state

This architecture makes it easy to add new commands and explicitily forbids motions like g2f, since only <modifier><command> is valid.

GO
 1// represent a fully detected vi motion
 2type viMessage struct {
 3	modifier uint
 4	command  string
 5}
 6
 7// convert the current vi state into a viMessage model.Update can deal with
 8func (v *vi) toViMessage() viMessage {
 9	msg := viMessage{
10		modifier: v.modifier,
11		command:  v.command.String(),
12	}
13	return msg
14}
15
16func (v *vi) update(msg tea.KeyMsg) (viMessage, bool) {
17	switch msg.Type {
18	case tea.KeyEsc:
19		v.reset()
20	case tea.KeyRunes:
21		if len(msg.Runes) != 1 {
22			return viMessage{}, false
23		}
24		k := msg.Runes[0]
25		switch {
26		case k >= '0' && k <= '9' && v.command.Len() == 0:
27			v.modifier = v.modifier*10 + uint(k-'0')
28		default:
29			if _, ok := validRunes[k]; ok {
30				v.command.WriteRune(k)
31				cmd := v.command.String()
32				if _, ok := validCommands[cmd]; ok {
33					vimsg := v.toViMessage()
34					v.reset()
35					return vimsg, true
36				}
37
38				if _, ok := validPrefixes[cmd]; !ok {
39					v.reset()
40				}
41			}
42		}
43	}
44
45	return viMessage{}, false
46}

Reset is as simple as it gets.

GO
1func (v *vi) reset() {
2	v.modifier = 0
3	v.command.Reset()
4}

Pending, Sprinting and Render(ing)

GO
 1func (msg viMessage) String() string {
 2	if msg.modifier == 0 || msg.modifier == 1 {
 3		return fmt.Sprint(msg.command)
 4	} else {
 5		return fmt.Sprint(msg.modifier, msg.command)
 6	}
 7}
 8
 9func (v *vi) pending() string {
10	return v.toViMessage().String()
11}

The pending is used to display the current pending motion at the right side of the status bar, similar to vim’s display:

GO
 1func (m model) status(width int) string {
 2    left := // [...] left side of the status bar
 3
 4	viPending := m.vi.pending()
 5
 6	left = lipgloss.NewStyle().
 7		Width(width - lipgloss.Width(viPending) - 4).
 8		Align(lipgloss.Left).
 9		Render(left)
10
11	return left + viPending
12}

In the View function status is called:

GO
 1func (m model) View() string {
 2	layout := lipgloss.JoinHorizontal(lipgloss.Top,
 3        // [...] the three panes 
 4        // | parent dir | current dir | preview | 
 5	)
 6
 7	status := statusStyle.Render(m.status(m.width))
 8    // joining produces:
 9    // 
10    // | parent dir | current dir | preview | 
11    // path fcount mcount              motion
12	return lipgloss.JoinVertical(lipgloss.Left, layout, status)
13}

Adding a new command

DIFF
 1diff --git a/ui/vi.go b/ui/vi.go
 2index 63cfa24..5087c6a 100644
 3--- a/ui/vi.go
 4+++ b/ui/vi.go
 5@@ -36,6 +36,7 @@ var validCommandList = []string{
 6        "q",
 7        "/",
 8        "a",
 9+       "i",
10 }
11 var validCommands = map[string]struct{}{}
12 var validRunes = map[rune]struct{}{}

Is all thats necessary for introducing a new binding, handling it works the same as shown before:

DIFF
 1diff --git a/ui/tea.go b/ui/tea.go
 2index 20d211a..a3123e7 100644
 3--- a/ui/tea.go
 4+++ b/ui/tea.go
 5@@ -77,6 +77,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 6  case tea.KeyMsg:
 7      if msg, some := m.vi.update(typed); some {
 8          switch msg.command {
 9+         case "i":
10+                 // i is used to Insert text into the current mail and maybe
11+                 // respond to it inline or something
12+                 return m, nil
13          case "q":
14                  return m, tea.Quit
15          }