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:

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:
TOML1# 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.
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:
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:
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}
validPrefixescould 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.
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:
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:
- if
escreset state and exit - if not single rune exit (rune is a go
char, see Code points, characters, and runes) - if numeric rune, update the
modifier - if a valid alhabetic rune, write it to the
commandbuffer - if the current string repr of the buffer (cmd) is a valid command, produce a
viMessage, reset vi state and return the msg - 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.
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.
1func (v *vi) reset() {
2 v.modifier = 0
3 v.command.Reset()
4}Pending, Sprinting and Render(ing)
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:
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:
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
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:
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 }