~sircmpwn/aerc

Ben Burwell: 4
 Add popovers
 Show textinput completions in popovers
 Don't use current input as a possible completion
 Add address book completion in composer

 15 files changed, 583 insertions(+), 86 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~sircmpwn/aerc/patches/9354/mbox | git am -3
Learn more about email & git

[PATCH v6 1/4] Add popovers Export this patch

A popover is a special UI element which can be layered over the rest of
the UI (i.e. it is painted last) and can fall anywhere on the screen,
not just with the bounds of its parent's viewport/context. With these
special abilities comes the restriction that only one popover may be
visible on screen at once.

Popovers are requested from the UI context passed to Draw calls and
specify the anchor point and the desired dimensions. The popover is then
fit to the available space and placed relative to the anchor point.
---
 lib/ui/context.go | 23 +++++++++++++-----
 lib/ui/popover.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++
 lib/ui/ui.go      | 26 +++++++++++++++++---
 3 files changed, 102 insertions(+), 9 deletions(-)
 create mode 100644 lib/ui/popover.go

diff --git a/lib/ui/context.go b/lib/ui/context.go
index d450fd8..6bdf76a 100644
--- a/lib/ui/context.go
+++ b/lib/ui/context.go
@@ -10,9 +10,10 @@ import (

// A context allows you to draw in a sub-region of the terminal
type Context struct {
	screen   tcell.Screen
	viewport *views.ViewPort
	x, y     int
	screen    tcell.Screen
	viewport  *views.ViewPort
	x, y      int
	onPopover func(*Popover)
}

func (ctx *Context) X() int {
@@ -35,9 +36,9 @@ func (ctx *Context) Height() int {
	return height
}

func NewContext(width, height int, screen tcell.Screen) *Context {
func NewContext(width, height int, screen tcell.Screen, p func(*Popover)) *Context {
	vp := views.NewViewPort(screen, 0, 0, width, height)
	return &Context{screen, vp, 0, 0}
	return &Context{screen, vp, 0, 0, p}
}

func (ctx *Context) Subcontext(x, y, width, height int) *Context {
@@ -49,7 +50,7 @@ func (ctx *Context) Subcontext(x, y, width, height int) *Context {
		panic(fmt.Errorf("Attempted to create context larger than parent"))
	}
	vp := views.NewViewPort(ctx.viewport, x, y, width, height)
	return &Context{ctx.screen, vp, ctx.x + x, ctx.y + y}
	return &Context{ctx.screen, vp, ctx.x + x, ctx.y + y, ctx.onPopover}
}

func (ctx *Context) SetCell(x, y int, ch rune, style tcell.Style) {
@@ -113,3 +114,13 @@ func (ctx *Context) SetCursor(x, y int) {
func (ctx *Context) HideCursor() {
	ctx.screen.HideCursor()
}

func (ctx *Context) Popover(x, y, width, height int, d Drawable) {
	ctx.onPopover(&Popover{
		x:       ctx.x + x,
		y:       ctx.y + y,
		width:   width,
		height:  height,
		content: d,
	})
}
diff --git a/lib/ui/popover.go b/lib/ui/popover.go
new file mode 100644
index 0000000..a76f222
--- /dev/null
+++ b/lib/ui/popover.go
@@ -0,0 +1,62 @@
package ui

import "github.com/gdamore/tcell"

type Popover struct {
	x, y, width, height int
	content             Drawable
}

func (p *Popover) Draw(ctx *Context) {
	var subcontext *Context

	// trim desired width to fit
	width := p.width
	if p.x+p.width > ctx.Width() {
		width = ctx.Width() - p.x
	}

	if p.y+p.height+1 < ctx.Height() {
		// draw below
		subcontext = ctx.Subcontext(p.x, p.y+1, width, p.height)
	} else if p.y-p.height >= 0 {
		// draw above
		subcontext = ctx.Subcontext(p.x, p.y-p.height, width, p.height)
	} else {
		// can't fit entirely above or below, so find the largest available
		// vertical space and shrink to fit
		if p.y > ctx.Height()-p.y {
			// there is more space above than below
			height := p.y
			subcontext = ctx.Subcontext(p.x, 0, width, height)
		} else {
			// there is more space below than above
			height := ctx.Height() - p.y
			subcontext = ctx.Subcontext(p.x, p.y+1, width, height-1)
		}
	}
	p.content.Draw(subcontext)
}

func (p *Popover) Event(e tcell.Event) bool {
	if di, ok := p.content.(DrawableInteractive); ok {
		return di.Event(e)
	}
	return false
}

func (p *Popover) Focus(f bool) {
	if di, ok := p.content.(DrawableInteractive); ok {
		di.Focus(f)
	}
}

func (p *Popover) Invalidate() {
	p.content.Invalidate()
}

func (p *Popover) OnInvalidate(f func(Drawable)) {
	p.content.OnInvalidate(func(_ Drawable) {
		f(p)
	})
}
diff --git a/lib/ui/ui.go b/lib/ui/ui.go
index 01d12dc..16b176d 100644
--- a/lib/ui/ui.go
+++ b/lib/ui/ui.go
@@ -11,6 +11,7 @@ type UI struct {
	exit    atomic.Value // bool
	ctx     *Context
	screen  tcell.Screen
	popover *Popover

	tcEvents chan tcell.Event
	invalid  int32 // access via atomic
@@ -34,11 +35,11 @@ func Initialize(content DrawableInteractiveBeeper) (*UI, error) {

	state := UI{
		Content: content,
		ctx:     NewContext(width, height, screen),
		screen:  screen,

		tcEvents: make(chan tcell.Event, 10),
	}
	state.ctx = NewContext(width, height, screen, state.onPopover)

	state.exit.Store(false)
	go func() {
@@ -57,6 +58,10 @@ func Initialize(content DrawableInteractiveBeeper) (*UI, error) {
	return &state, nil
}

func (state *UI) onPopover(p *Popover) {
	state.popover = p
}

func (state *UI) ShouldExit() bool {
	return state.exit.Load().(bool)
}
@@ -78,17 +83,32 @@ func (state *UI) Tick() bool {
		case *tcell.EventResize:
			state.screen.Clear()
			width, height := event.Size()
			state.ctx = NewContext(width, height, state.screen)
			state.ctx = NewContext(width, height, state.screen, state.onPopover)
			state.Content.Invalidate()
		}
		state.Content.Event(event)
		// if we have a popover, and it can handle the event, it does so
		if state.popover == nil || !state.popover.Event(event) {
			// otherwise, we send the event to the main content
			state.Content.Event(event)
		}
		more = true
	default:
	}

	wasInvalid := atomic.SwapInt32(&state.invalid, 0)
	if wasInvalid != 0 {
		if state.popover != nil {
			// if the previous frame had a popover, rerender the entire display
			state.Content.Invalidate()
			atomic.StoreInt32(&state.invalid, 0)
		}
		// reset popover for the next Draw
		state.popover = nil
		state.Content.Draw(state.ctx)
		if state.popover != nil {
			// if the Draw resulted in a popover, draw it
			state.popover.Draw(state.ctx)
		}
		state.screen.Show()
		more = true
	}
-- 
2.24.1
Looks great, thanks!

To git.sr.ht:~sircmpwn/aerc
   ef4c2f6..fad375c  master -> master

[PATCH v6 2/4] Show textinput completions in popovers Export this patch

Rather than showing completions inline in the text input, show them in a
popover which can be scrolled by repeatedly pressing the tab key. The
selected completion can be executed by pressing enter.
---
 config/aerc.conf.in   |  11 ++
 config/config.go      |  35 +++---
 doc/aerc-config.5.scd |  10 ++
 lib/ui/textinput.go   | 273 ++++++++++++++++++++++++++++++++++--------
 widgets/aerc.go       |   4 +-
 widgets/exline.go     |  15 ++-
 6 files changed, 277 insertions(+), 71 deletions(-)

diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index 16e3da1..660a525 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -99,6 +99,17 @@ header-layout=From|To,Cc|Bcc,Date,Subject
# Default: false
always-show-mime=false

# How long to wait after the last input before auto-completion is triggered.
#
# Default: 250ms
completion-delay=250ms

#
# Global switch for completion popovers
#
# Default: true
completion-popovers=true

[compose]
#
# Specifies the command to run the editor with. It will be shown in an embedded
diff --git a/config/config.go b/config/config.go
index dd1f5f4..d6afef6 100644
--- a/config/config.go
+++ b/config/config.go
@@ -11,6 +11,7 @@ import (
	"regexp"
	"sort"
	"strings"
	"time"
	"unicode"

	"github.com/gdamore/tcell"
@@ -25,21 +26,23 @@ type GeneralConfig struct {
}

type UIConfig struct {
	IndexFormat         string   `ini:"index-format"`
	TimestampFormat     string   `ini:"timestamp-format"`
	ShowHeaders         []string `delim:","`
	RenderAccountTabs   string   `ini:"render-account-tabs"`
	SidebarWidth        int      `ini:"sidebar-width"`
	PreviewHeight       int      `ini:"preview-height"`
	EmptyMessage        string   `ini:"empty-message"`
	EmptyDirlist        string   `ini:"empty-dirlist"`
	MouseEnabled        bool     `ini:"mouse-enabled"`
	NewMessageBell      bool     `ini:"new-message-bell"`
	Spinner             string   `ini:"spinner"`
	SpinnerDelimiter    string   `ini:"spinner-delimiter"`
	DirListFormat       string   `ini:"dirlist-format"`
	Sort                []string `delim:" "`
	NextMessageOnDelete bool     `ini:"next-message-on-delete"`
	IndexFormat         string        `ini:"index-format"`
	TimestampFormat     string        `ini:"timestamp-format"`
	ShowHeaders         []string      `delim:","`
	RenderAccountTabs   string        `ini:"render-account-tabs"`
	SidebarWidth        int           `ini:"sidebar-width"`
	PreviewHeight       int           `ini:"preview-height"`
	EmptyMessage        string        `ini:"empty-message"`
	EmptyDirlist        string        `ini:"empty-dirlist"`
	MouseEnabled        bool          `ini:"mouse-enabled"`
	NewMessageBell      bool          `ini:"new-message-bell"`
	Spinner             string        `ini:"spinner"`
	SpinnerDelimiter    string        `ini:"spinner-delimiter"`
	DirListFormat       string        `ini:"dirlist-format"`
	Sort                []string      `delim:" "`
	NextMessageOnDelete bool          `ini:"next-message-on-delete"`
	CompletionDelay     time.Duration `ini:"completion-delay"`
	CompletionPopovers  bool          `ini:"completion-popovers"`
}

const (
@@ -387,6 +390,8 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
			SpinnerDelimiter:    ",",
			DirListFormat:       "%n %>r",
			NextMessageOnDelete: true,
			CompletionDelay:     250 * time.Millisecond,
			CompletionPopovers:  true,
		},

		Viewer: ViewerConfig{
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 2eb04f1..01abefe 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -156,6 +156,16 @@ These options are configured in the *[ui]* section of aerc.conf.

	Default: true

*completion-popovers*
	Shows potential auto-completions for text inputs in popovers.

	Default: true

*completion-delay*
	How long to wait after the last input before auto-completion is triggered.

	Default: 250ms

## VIEWER

These options are configured in the *[viewer]* section of aerc.conf.
diff --git a/lib/ui/textinput.go b/lib/ui/textinput.go
index e81e836..de7557a 100644
--- a/lib/ui/textinput.go
+++ b/lib/ui/textinput.go
@@ -1,6 +1,9 @@
package ui

import (
	"math"
	"time"

	"github.com/gdamore/tcell"
	"github.com/mattn/go-runewidth"
)
@@ -10,18 +13,20 @@ import (

type TextInput struct {
	Invalidatable
	cells         int
	ctx           *Context
	focus         bool
	index         int
	password      bool
	prompt        string
	scroll        int
	text          []rune
	change        []func(ti *TextInput)
	tabcomplete   func(s string) []string
	completions   []string
	completeIndex int
	cells             int
	ctx               *Context
	focus             bool
	index             int
	password          bool
	prompt            string
	scroll            int
	text              []rune
	change            []func(ti *TextInput)
	tabcomplete       func(s string) []string
	completions       []string
	completeIndex     int
	completeDelay     time.Duration
	completeDebouncer *time.Timer
}

// Creates a new TextInput. TextInputs will render a "textbox" in the entire
@@ -46,8 +51,9 @@ func (ti *TextInput) Prompt(prompt string) *TextInput {
}

func (ti *TextInput) TabComplete(
	tabcomplete func(s string) []string) *TextInput {
	tabcomplete func(s string) []string, d time.Duration) *TextInput {
	ti.tabcomplete = tabcomplete
	ti.completeDelay = d
	return ti
}

@@ -95,9 +101,37 @@ func (ti *TextInput) Draw(ctx *Context) {
	cells := runewidth.StringWidth(string(text[:sindex]) + ti.prompt)
	if ti.focus {
		ctx.SetCursor(cells, 0)
		ti.drawPopover(ctx)
	}
}

func (ti *TextInput) drawPopover(ctx *Context) {
	if len(ti.completions) == 0 {
		return
	}
	cmp := &completions{
		options:    ti.completions,
		idx:        ti.completeIndex,
		stringLeft: ti.StringLeft(),
		onSelect: func(idx int) {
			ti.completeIndex = idx
			ti.Invalidate()
		},
		onExec: func() {
			ti.executeCompletion()
			ti.invalidateCompletions()
			ti.Invalidate()
		},
		onStem: func(stem string) {
			ti.Set(stem + ti.StringRight())
			ti.Invalidate()
		},
	}
	width := maxLen(ti.completions) + 3
	height := len(ti.completions)
	ctx.Popover(0, 0, width, height, cmp)
}

func (ti *TextInput) MouseEvent(localX int, localY int, event tcell.Event) {
	switch event := event.(type) {
	case *tcell.EventMouse:
@@ -208,32 +242,7 @@ func (ti *TextInput) backspace() {
	}
}

func (ti *TextInput) nextCompletion() {
	if ti.completions == nil {
		if ti.tabcomplete == nil {
			return
		}
		ti.completions = ti.tabcomplete(ti.StringLeft())
		ti.completeIndex = 0
	} else {
		ti.completeIndex++
		if ti.completeIndex >= len(ti.completions) {
			ti.completeIndex = 0
		}
	}
	if len(ti.completions) > 0 {
		ti.Set(ti.completions[ti.completeIndex] + ti.StringRight())
	}
}

func (ti *TextInput) previousCompletion() {
	if ti.completions == nil || len(ti.completions) == 0 {
		return
	}
	ti.completeIndex--
	if ti.completeIndex < 0 {
		ti.completeIndex = len(ti.completions) - 1
	}
func (ti *TextInput) executeCompletion() {
	if len(ti.completions) > 0 {
		ti.Set(ti.completions[ti.completeIndex] + ti.StringRight())
	}
@@ -244,11 +253,33 @@ func (ti *TextInput) invalidateCompletions() {
}

func (ti *TextInput) onChange() {
	ti.updateCompletions()
	for _, change := range ti.change {
		change(ti)
	}
}

func (ti *TextInput) updateCompletions() {
	if ti.tabcomplete == nil {
		// no completer
		return
	}
	if ti.completeDebouncer == nil {
		ti.completeDebouncer = time.AfterFunc(ti.completeDelay, func() {
			ti.showCompletions()
		})
	} else {
		ti.completeDebouncer.Stop()
		ti.completeDebouncer.Reset(ti.completeDelay)
	}
}

func (ti *TextInput) showCompletions() {
	ti.completions = ti.tabcomplete(ti.StringLeft())
	ti.completeIndex = 0
	ti.Invalidate()
}

func (ti *TextInput) OnChange(onChange func(ti *TextInput)) {
	ti.change = append(ti.change, onChange)
}
@@ -296,18 +327,13 @@ func (ti *TextInput) Event(event tcell.Event) bool {
		case tcell.KeyCtrlU:
			ti.invalidateCompletions()
			ti.deleteLineBackward()
		case tcell.KeyTab:
			if ti.tabcomplete != nil {
				ti.nextCompletion()
			} else {
				ti.insert('\t')
			}
			ti.Invalidate()
		case tcell.KeyBacktab:
			if ti.tabcomplete != nil {
				ti.previousCompletion()
		case tcell.KeyESC:
			if ti.completions != nil {
				ti.invalidateCompletions()
				ti.Invalidate()
			}
			ti.Invalidate()
		case tcell.KeyTab:
			ti.showCompletions()
		case tcell.KeyRune:
			ti.invalidateCompletions()
			ti.insert(event.Rune())
@@ -315,3 +341,150 @@ func (ti *TextInput) Event(event tcell.Event) bool {
	}
	return true
}

type completions struct {
	options    []string
	stringLeft string
	idx        int
	onSelect   func(int)
	onExec     func()
	onStem     func(string)
}

func maxLen(ss []string) int {
	max := 0
	for _, s := range ss {
		l := runewidth.StringWidth(s)
		if l > max {
			max = l
		}
	}
	return max
}

func (c *completions) Draw(ctx *Context) {
	bg := tcell.StyleDefault
	sel := tcell.StyleDefault.Reverse(true)
	gutter := tcell.StyleDefault
	pill := tcell.StyleDefault.Reverse(true)

	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', bg)

	numVisible := ctx.Height()
	startIdx := 0
	if len(c.options) > numVisible && c.idx+1 > numVisible {
		startIdx = c.idx - (numVisible - 1)
	}
	endIdx := startIdx + numVisible - 1

	for idx, opt := range c.options {
		if idx < startIdx {
			continue
		}
		if idx > endIdx {
			continue
		}
		if c.idx == idx {
			ctx.Fill(0, idx-startIdx, ctx.Width(), 1, ' ', sel)
			ctx.Printf(0, idx-startIdx, sel, " %s ", opt)
		} else {
			ctx.Printf(0, idx-startIdx, bg, " %s ", opt)
		}
	}

	percentVisible := float64(numVisible) / float64(len(c.options))
	if percentVisible >= 1.0 {
		return
	}

	// gutter
	ctx.Fill(ctx.Width()-1, 0, 1, ctx.Height(), ' ', gutter)

	pillSize := int(math.Ceil(float64(ctx.Height()) * percentVisible))
	percentScrolled := float64(startIdx) / float64(len(c.options))
	pillOffset := int(math.Floor(float64(ctx.Height()) * percentScrolled))
	ctx.Fill(ctx.Width()-1, pillOffset, 1, pillSize, ' ', pill)
}

func (c *completions) next() {
	idx := c.idx
	idx++
	if idx > len(c.options)-1 {
		idx = 0
	}
	c.onSelect(idx)
}

func (c *completions) prev() {
	idx := c.idx
	idx--
	if idx < 0 {
		idx = len(c.options) - 1
	}
	c.onSelect(idx)
}

func (c *completions) Event(e tcell.Event) bool {
	switch e := e.(type) {
	case *tcell.EventKey:
		switch e.Key() {
		case tcell.KeyTab:
			if len(c.options) == 1 {
				c.onExec()
			} else {
				stem := findStem(c.options)
				if stem != "" && stem != c.stringLeft {
					c.onStem(stem)
				} else {
					c.next()
				}
			}
			return true
		case tcell.KeyCtrlN, tcell.KeyDown:
			c.next()
			return true
		case tcell.KeyBacktab, tcell.KeyCtrlP, tcell.KeyUp:
			c.prev()
			return true
		case tcell.KeyEnter:
			c.onExec()
			return true
		}
	}
	return false
}

func findStem(words []string) string {
	if len(words) <= 0 {
		return ""
	}
	if len(words) == 1 {
		return words[0]
	}
	var stem string
	stemLen := 1
	firstWord := []rune(words[0])
	for {
		if len(firstWord) < stemLen {
			return stem
		}
		var r rune = firstWord[stemLen-1]
		for _, word := range words[1:] {
			runes := []rune(word)
			if len(runes) < stemLen {
				return stem
			}
			if runes[stemLen-1] != r {
				return stem
			}
		}
		stem = stem + string(r)
		stemLen++
	}
}

func (c *completions) Focus(_ bool) {}

func (c *completions) Invalidate() {}

func (c *completions) OnInvalidate(_ func(Drawable)) {}
diff --git a/widgets/aerc.go b/widgets/aerc.go
index 9d955e1..da3f56f 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -372,7 +372,7 @@ func (aerc *Aerc) focus(item ui.Interactive) {

func (aerc *Aerc) BeginExCommand(cmd string) {
	previous := aerc.focused
	exline := NewExLine(cmd, func(cmd string) {
	exline := NewExLine(aerc.conf, cmd, func(cmd string) {
		parts, err := shlex.Split(cmd)
		if err != nil {
			aerc.PushStatus(" "+err.Error(), 10*time.Second).
@@ -399,7 +399,7 @@ func (aerc *Aerc) BeginExCommand(cmd string) {
}

func (aerc *Aerc) RegisterPrompt(prompt string, cmd []string) {
	p := NewPrompt(prompt, func(text string) {
	p := NewPrompt(aerc.conf, prompt, func(text string) {
		if text != "" {
			cmd = append(cmd, text)
		}
diff --git a/widgets/exline.go b/widgets/exline.go
index f2c7249..6def938 100644
--- a/widgets/exline.go
+++ b/widgets/exline.go
@@ -3,6 +3,7 @@ package widgets
import (
	"github.com/gdamore/tcell"

	"git.sr.ht/~sircmpwn/aerc/config"
	"git.sr.ht/~sircmpwn/aerc/lib"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"
)
@@ -16,11 +17,14 @@ type ExLine struct {
	input       *ui.TextInput
}

func NewExLine(cmd string, commit func(cmd string), finish func(),
func NewExLine(conf *config.AercConfig, cmd string, commit func(cmd string), finish func(),
	tabcomplete func(cmd string) []string,
	cmdHistory lib.History) *ExLine {

	input := ui.NewTextInput("").Prompt(":").TabComplete(tabcomplete).Set(cmd)
	input := ui.NewTextInput("").Prompt(":").Set(cmd)
	if conf.Ui.CompletionPopovers {
		input.TabComplete(tabcomplete, conf.Ui.CompletionDelay)
	}
	exline := &ExLine{
		commit:      commit,
		finish:      finish,
@@ -34,10 +38,13 @@ func NewExLine(cmd string, commit func(cmd string), finish func(),
	return exline
}

func NewPrompt(prompt string, commit func(text string),
func NewPrompt(conf *config.AercConfig, prompt string, commit func(text string),
	tabcomplete func(cmd string) []string) *ExLine {

	input := ui.NewTextInput("").Prompt(prompt).TabComplete(tabcomplete)
	input := ui.NewTextInput("").Prompt(prompt)
	if conf.Ui.CompletionPopovers {
		input.TabComplete(tabcomplete, conf.Ui.CompletionDelay)
	}
	exline := &ExLine{
		commit:      commit,
		tabcomplete: tabcomplete,
-- 
2.24.1

[PATCH v6 3/4] Don't use current input as a possible completion Export this patch

Now that completions are being shown in the popover, it doesn't make
sense to show the unfinished command as a potential completion.
---
 aerc.go | 1 -
 1 file changed, 1 deletion(-)

diff --git a/aerc.go b/aerc.go
index e8944d7..028cc6a 100644
--- a/aerc.go
+++ b/aerc.go
@@ -78,7 +78,6 @@ func getCompletions(aerc *widgets.Aerc, cmd string) []string {
	for _, set := range getCommands((*aerc).SelectedTab()) {
		completions = append(completions, set.GetCompletions(aerc, cmd)...)
	}
	completions = append(completions, cmd)
	return completions
}

-- 
2.24.1

[PATCH v6 4/4] Add address book completion in composer Export this patch

Complete email address fields in the message composer with an external
address book command, compatible with mutt's query_cmd.
---
 completer/completer.go | 153 +++++++++++++++++++++++++++++++++++++++++
 config/aerc.conf.in    |  12 ++++
 config/config.go       |   5 +-
 doc/aerc-config.5.scd  |  16 +++++
 widgets/compose.go     |  23 ++++++-
 5 files changed, 204 insertions(+), 5 deletions(-)
 create mode 100644 completer/completer.go

diff --git a/completer/completer.go b/completer/completer.go
new file mode 100644
index 0000000..baa897d
--- /dev/null
+++ b/completer/completer.go
@@ -0,0 +1,153 @@
package completer

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"net/mail"
	"os/exec"
	"strings"

	"github.com/google/shlex"
)

// A Completer is used to autocomplete text inputs based on the configured
// completion commands.
type Completer struct {
	// AddressBookCmd is the command to run for completing email addresses. This
	// command must output one completion on each line with fields separated by a
	// tab character. The first field must be the address, and the second field,
	// if present, the contact name. Only the email address field is required.
	// The name field is optional. Additional fields are ignored.
	AddressBookCmd string

	errHandler func(error)
	logger     *log.Logger
}

// A CompleteFunc accepts a string to be completed and returns a slice of
// possible completions.
type CompleteFunc func(string) []string

// New creates a new Completer with the specified address book command.
func New(addressBookCmd string, errHandler func(error), logger *log.Logger) *Completer {
	return &Completer{
		AddressBookCmd: addressBookCmd,
		errHandler:     errHandler,
		logger:         logger,
	}
}

// ForHeader returns a CompleteFunc appropriate for the specified mail header. In
// the case of To, From, etc., the completer will get completions from the
// configured address book command. For other headers, a noop completer will be
// returned. If errors arise during completion, the errHandler will be called.
func (c *Completer) ForHeader(h string) CompleteFunc {
	if isAddressHeader(h) {
		if c.AddressBookCmd == "" {
			return nil
		}
		// wrap completeAddress in an error handler
		return func(s string) []string {
			completions, err := c.completeAddress(s)
			if err != nil {
				c.handleErr(err)
				return []string{}
			}
			return completions
		}
	}
	return nil
}

// isAddressHeader determines whether the address completer should be used for
// header h.
func isAddressHeader(h string) bool {
	switch strings.ToLower(h) {
	case "to", "from", "cc", "bcc":
		return true
	}
	return false
}

// completeAddress uses the configured address book completion command to fetch
// completions for the specified string, returning a slice of completions or an
// error.
func (c *Completer) completeAddress(s string) ([]string, error) {
	cmd, err := c.getAddressCmd(s)
	if err != nil {
		return nil, err
	}
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, fmt.Errorf("stdout: %v", err)
	}
	if err := cmd.Start(); err != nil {
		return nil, fmt.Errorf("cmd start: %v", err)
	}
	completions, err := readCompletions(stdout)
	if err != nil {
		return nil, fmt.Errorf("read completions: %v", err)
	}

	// Wait returns an error if the exit status != 0, which some completion
	// programs will do to signal no matches. We don't want to spam the user with
	// spurious error messages, so we'll ignore any errors that arise at this
	// point.
	if err := cmd.Wait(); err != nil {
		c.logger.Printf("completion error: %v", err)
	}

	return completions, nil
}

// getAddressCmd constructs an exec.Cmd based on the configured command and
// specified query.
func (c *Completer) getAddressCmd(s string) (*exec.Cmd, error) {
	if strings.TrimSpace(c.AddressBookCmd) == "" {
		return nil, fmt.Errorf("no command configured")
	}
	queryCmd := strings.Replace(c.AddressBookCmd, "%s", s, -1)
	parts, err := shlex.Split(queryCmd)
	if err != nil {
		return nil, fmt.Errorf("could not lex command")
	}
	if len(parts) < 1 {
		return nil, fmt.Errorf("empty command")
	}
	if len(parts) > 1 {
		return exec.Command(parts[0], parts[1:]...), nil
	}
	return exec.Command(parts[0]), nil
}

// readCompletions reads a slice of completions from r line by line. Each line
// must consist of tab-delimited fields. Only the first field (the email
// address field) is required, the second field (the contact name) is optional,
// and subsequent fields are ignored.
func readCompletions(r io.Reader) ([]string, error) {
	buf := bufio.NewReader(r)
	completions := []string{}
	for {
		line, err := buf.ReadString('\n')
		if err == io.EOF {
			return completions, nil
		} else if err != nil {
			return nil, err
		}
		parts := strings.SplitN(line, "\t", 3)
		if addr, err := mail.ParseAddress(parts[0]); err == nil {
			if len(parts) > 1 {
				addr.Name = parts[1]
			}
			completions = append(completions, addr.String())
		}
	}
}

func (c *Completer) handleErr(err error) {
	if c.errHandler != nil {
		c.errHandler(err)
	}
}
diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index 660a525..5feeac0 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -124,6 +124,18 @@ editor=
# Default: To|From,Subject
header-layout=To|From,Subject

#
# Specifies the command to be used to tab-complete email addresses. Any
# occurrence of "%s" in the address-book-cmd will be replaced with what the
# user has typed so far.
#
# The command must output the completions to standard output, one completion
# per line. Each line must be tab-delimited, with an email address occurring as
# the first field. Only the email address field is required. The second field,
# if present, will be treated as the contact name. Additional fields are
# ignored.
address-book-cmd=

[filters]
#
# Filters allow you to pipe an email body through a shell command to render
diff --git a/config/config.go b/config/config.go
index d6afef6..e5f7395 100644
--- a/config/config.go
+++ b/config/config.go
@@ -79,8 +79,9 @@ type BindingConfig struct {
}

type ComposeConfig struct {
	Editor       string     `ini:"editor"`
	HeaderLayout [][]string `ini:"-"`
	Editor         string     `ini:"editor"`
	HeaderLayout   [][]string `ini:"-"`
	AddressBookCmd string     `ini:"address-book-cmd"`
}

type FilterConfig struct {
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 01abefe..615c3ab 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -218,6 +218,22 @@ These options are configured in the *[compose]* section of aerc.conf.

	Default: To|From,Subject

*address-book-cmd*
	Specifies the command to be used to tab-complete email addresses. Any
	occurrence of "%s" in the address-book-cmd will be replaced with what the
	user has typed so far.

	The command must output the completions to standard output, one completion
	per line. Each line must be tab-delimited, with an email address occurring as
	the first field. Only the email address field is required. The second field,
	if present, will be treated as the contact name. Additional fields are
	ignored.

	Example:
		khard email --parsable '%s'

	Default: none

## FILTERS

Filters allow you to pipe an email body through a shell command to render
diff --git a/widgets/compose.go b/widgets/compose.go
index 242b6db..091eb70 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -22,6 +22,7 @@ import (
	"github.com/mitchellh/go-homedir"
	"github.com/pkg/errors"

	"git.sr.ht/~sircmpwn/aerc/completer"
	"git.sr.ht/~sircmpwn/aerc/config"
	"git.sr.ht/~sircmpwn/aerc/lib/templates"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"
@@ -45,6 +46,7 @@ type Composer struct {
	msgId       string
	review      *reviewMessage
	worker      *types.Worker
	completer   *completer.Completer

	layout    HeaderLayout
	focusable []ui.MouseableDrawableInteractive
@@ -67,8 +69,11 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
	}

	templateData := templates.ParseTemplateData(defaults)
	layout, editors, focusable := buildComposeHeader(
		conf.Compose.HeaderLayout, defaults)
	cmpl := completer.New(conf.Compose.AddressBookCmd, func(err error) {
		aerc.PushError(fmt.Sprintf("could not complete header: %v", err))
		worker.Logger.Printf("could not complete header: %v", err)
	}, aerc.Logger())
	layout, editors, focusable := buildComposeHeader(conf, cmpl, defaults)

	email, err := ioutil.TempFile("", "aerc-compose-*.eml")
	if err != nil {
@@ -90,6 +95,7 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
		// You have to backtab to get to "From", since you usually don't edit it
		focused:   1,
		focusable: focusable,
		completer: cmpl,
	}

	c.AddSignature()
@@ -103,17 +109,22 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
	return c, nil
}

func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
func buildComposeHeader(conf *config.AercConfig, cmpl *completer.Completer,
	defaults map[string]string) (
	newLayout HeaderLayout,
	editors map[string]*headerEditor,
	focusable []ui.MouseableDrawableInteractive,
) {
	layout := conf.Compose.HeaderLayout
	editors = make(map[string]*headerEditor)
	focusable = make([]ui.MouseableDrawableInteractive, 0)

	for _, row := range layout {
		for _, h := range row {
			e := newHeaderEditor(h, "")
			if conf.Ui.CompletionPopovers {
				e.input.TabComplete(cmpl.ForHeader(h), conf.Ui.CompletionDelay)
			}
			editors[h] = e
			switch h {
			case "From":
@@ -130,6 +141,9 @@ func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
		if val, ok := defaults[h]; ok && val != "" {
			if _, ok := editors[h]; !ok {
				e := newHeaderEditor(h, "")
				if conf.Ui.CompletionPopovers {
					e.input.TabComplete(cmpl.ForHeader(h), conf.Ui.CompletionDelay)
				}
				editors[h] = e
				focusable = append(focusable, e)
				layout = append(layout, []string{h})
@@ -725,6 +739,9 @@ func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
		return
	}
	e := newHeaderEditor(header, value)
	if c.config.Ui.CompletionPopovers {
		e.input.TabComplete(c.completer.ForHeader(header), c.config.Ui.CompletionDelay)
	}
	c.editors[header] = e
	c.layout = append(c.layout, []string{header})
	// Insert focus of new editor before terminal editor
-- 
2.24.1