~sircmpwn/aerc

Address book completion v2 PROPOSED

Finally, a reroll on the address book completion patch. This one comes
with Popovers, a new UI element which is used to display the suggested
completions.

In order to let the textinput widget handle the tab key in the context
of the composer, where tab is also bound to :next-field, I realized the
bubbling direction of events needed to be swapped. Widgets should pass
events to their children for handling first, and only when the children
do not handle the events should the parents do so. In the context of
completion, this means that the tab key press gets first handled by the
textinput, but if the popover is not visible, the textinput will return
false from the event handler, signalling that the event wasn't handled
and should be processed by the parent.

This means that key bindings lose priority to events which can be
handled directly by the focused interactive element. I *think* this is
the correct approach, but I am not positive. As a side effect, I needed
to remove the Ctrl-K binding from textinput in order for the :prev-field
binding during compose to take effect.

I tried to model the completion appearance/behavior after vim; the
magenta is fairly arbitrary, just what my vim happens to do. I expect
it'll be themable once that exists, but if anyone has serious objections
I suppose it could be changed.

TODO:

* Add scrolling in the completion popover
* Allow completion to be globally disabled
* Allow completion to be disabled per-element
* Be smart about address completion when there are already > 0 addresses
  entered in the input

Ben Burwell (5):
  Add popovers
  Invert event bubbling
  Show textinput completions in popovers
  Don't use current input as a possible completion
  Add address book completion in composer

 aerc.go                |   1 -
 completer/completer.go | 138 +++++++++++++++++++++++++++++++++++++++++
 config/aerc.conf.in    |  17 +++++
 config/config.go       |  38 +++++++-----
 doc/aerc-config.5.scd  |  21 +++++++
 lib/ui/context.go      |  18 +++++-
 lib/ui/popover.go      |  48 ++++++++++++++
 lib/ui/textinput.go    | 112 ++++++++++++++++++++++++++++-----
 lib/ui/ui.go           |  18 +++++-
 widgets/aerc.go        |  19 ++++--
 widgets/compose.go     |  22 ++++---
 widgets/exline.go      |  15 +++--
 12 files changed, 410 insertions(+), 57 deletions(-)
 create mode 100644 completer/completer.go
 create mode 100644 lib/ui/popover.go

-- 
2.24.0
> Before, events bubbled down from the top level aerc widget through to
> children. However, this does not allow interactive child widgets the
> opportunity to handle events before they are captured as key-bound
> events by the top level aerc widget, which is necessary to implement
> tab completion.
Okay, I just noticed a problem with this (other than needing to unbind
Ctrl-K in the textinput widget). When I'm composing a message in vim,
and I use Ctrl-N/Ctrl-P to switch aerc tabs, those key bindings get sent
to vim and have done things in my editor when I return to the compose
tab.

This may require a slightly different approach. Still kinda trying to
wrap my head around "key bindings should take precedence except when
they shouldn't."
> This may require a slightly different approach. Still kinda trying to
> wrap my head around "key bindings should take precedence except when
> they shouldn't."
Terminal multiplexers solve this with prefix bindings and I don't think
there's a better solution than that in general. Even though it's not its
main purpose, aerc is basically a terminal multiplexer as well.
After thinking about this a bit more, I think that part of the special
treatment of popovers is that they get first crack at event handling. To
me, this makes sense given that they are effectively at a higher z-index
than the rest of the UI. So if a popover is being rendered when an event
fires, it goes to the popover first. If the popover does not return true
from Event() marking it as handled, it traverses down the widget
hierarchy as before.

I've updated my patchset to use this approach; this means that the
event bubbling doesn't need to invert. I'm going to play with it a bit
more locally before sending v3, but if anyone has feedback on the rest
of v2 in the meantime, let me know.
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/9108/mbox | git am -3
Learn more about email & git

[PATCH v2 1/5] 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 | 18 +++++++++++++++---
 lib/ui/popover.go | 48 +++++++++++++++++++++++++++++++++++++++++++++++
 lib/ui/ui.go      | 18 ++++++++++++++++--
 3 files changed, 79 insertions(+), 5 deletions(-)
 create mode 100644 lib/ui/popover.go

diff --git a/lib/ui/context.go b/lib/ui/context.go
index d450fd8..4108f0b 100644
--- a/lib/ui/context.go
+++ b/lib/ui/context.go
@@ -13,6 +13,8 @@ type Context struct {
 	screen   tcell.Screen
 	viewport *views.ViewPort
 	x, y     int
+
+	onPopover func(Drawable)
 }
 
 func (ctx *Context) X() int {
@@ -35,9 +37,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(Drawable)) *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 +51,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 +115,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..42adb87
--- /dev/null
+++ b/lib/ui/popover.go
@@ -0,0 +1,48 @@
+package ui
+
+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) 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..5d71c30 100644
--- a/lib/ui/ui.go
+++ b/lib/ui/ui.go
@@ -12,6 +12,8 @@ type UI struct {
 	ctx     *Context
 	screen  tcell.Screen
 
+	popover Drawable
+
 	tcEvents chan tcell.Event
 	invalid  int32 // access via atomic
 }
@@ -34,12 +36,13 @@ 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() {
 		for !state.ShouldExit() {
@@ -57,6 +60,10 @@ func Initialize(content DrawableInteractiveBeeper) (*UI, error) {
 	return &state, nil
 }
 
+func (state *UI) onPopover(d Drawable) {
+	state.popover = d
+}
+
 func (state *UI) ShouldExit() bool {
 	return state.exit.Load().(bool)
 }
@@ -78,7 +85,7 @@ 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)
@@ -88,7 +95,14 @@ func (state *UI) Tick() bool {
 
 	wasInvalid := atomic.SwapInt32(&state.invalid, 0)
 	if wasInvalid != 0 {
+		if state.popover != nil {
+			state.Content.Invalidate()
+		}
+		state.popover = nil
 		state.Content.Draw(state.ctx)
+		if state.popover != nil {
+			state.popover.Draw(state.ctx)
+		}
 		state.screen.Show()
 		more = true
 	}
-- 
2.24.0

[PATCH v2 2/5] Invert event bubbling Export this patch

Before, events bubbled down from the top level aerc widget through to
children. However, this does not allow interactive child widgets the
opportunity to handle events before they are captured as key-bound
events by the top level aerc widget, which is necessary to implement tab
completion.
---
 widgets/aerc.go   | 15 +++++++++++----
 widgets/exline.go |  6 ++++--
 2 files changed, 15 insertions(+), 6 deletions(-)

diff --git a/widgets/aerc.go b/widgets/aerc.go
index 9d955e1..2897c90 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -201,6 +201,17 @@ func (aerc *Aerc) Event(event tcell.Event) bool {
 		return aerc.focused.Event(event)
 	}
 
+	// first, if not simulating, try to have the event handled by the content if interactive
+	if aerc.simulating <= 0 {
+		if interactive, ok := aerc.tabs.Tabs[aerc.tabs.Selected].Content.(ui.Interactive); ok {
+			if interactive.Event(event) {
+				return true
+			} else {
+				// the interactive element didn't finish handling the event, continue
+			}
+		}
+	}
+
 	switch event := event.(type) {
 	case *tcell.EventKey:
 		aerc.statusline.Expire()
@@ -243,10 +254,6 @@ func (aerc *Aerc) Event(event tcell.Event) bool {
 				aerc.BeginExCommand("")
 				return true
 			}
-			interactive, ok := aerc.tabs.Tabs[aerc.tabs.Selected].Content.(ui.Interactive)
-			if ok {
-				return interactive.Event(event)
-			}
 			return false
 		}
 	case *tcell.EventMouse:
diff --git a/widgets/exline.go b/widgets/exline.go
index f2c7249..d992ed5 100644
--- a/widgets/exline.go
+++ b/widgets/exline.go
@@ -63,6 +63,10 @@ func (ex *ExLine) Focus(focus bool) {
 }
 
 func (ex *ExLine) Event(event tcell.Event) bool {
+	if ex.input.Event(event) {
+		return true
+	}
+
 	switch event := event.(type) {
 	case *tcell.EventKey:
 		switch event.Key() {
@@ -81,8 +85,6 @@ func (ex *ExLine) Event(event tcell.Event) bool {
 			ex.input.Focus(false)
 			ex.cmdHistory.Reset()
 			ex.finish()
-		default:
-			return ex.input.Event(event)
 		}
 	}
 	return true
-- 
2.24.0
> Before, events bubbled down from the top level aerc widget through to
> children. However, this does not allow interactive child widgets the
> opportunity to handle events before they are captured as key-bound
> events by the top level aerc widget, which is necessary to implement
> tab completion.
Okay, I just noticed a problem with this (other than needing to unbind
Ctrl-K in the textinput widget). When I'm composing a message in vim,
and I use Ctrl-N/Ctrl-P to switch aerc tabs, those key bindings get sent
to vim and have done things in my editor when I return to the compose
tab.

This may require a slightly different approach. Still kinda trying to
wrap my head around "key bindings should take precedence except when
they shouldn't."

[PATCH v2 3/5] 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   |   5 ++
 config/config.go      |  33 +++++++------
 doc/aerc-config.5.scd |   5 ++
 lib/ui/textinput.go   | 112 ++++++++++++++++++++++++++++++++++++------
 widgets/aerc.go       |   4 +-
 widgets/exline.go     |   9 ++--
 6 files changed, 132 insertions(+), 36 deletions(-)

diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index 16e3da1..91523c4 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -99,6 +99,11 @@ 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
+
 [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 32d07fc..f0342d8 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,22 @@ 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"`
 }
 
 const (
@@ -386,6 +388,7 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
 			SpinnerDelimiter:    ",",
 			DirListFormat:       "%n %>r",
 			NextMessageOnDelete: true,
+			CompletionDelay:     250 * time.Millisecond,
 		},
 
 		Viewer: ViewerConfig{
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 02fe4d6..af047da 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -156,6 +156,11 @@ These options are configured in the *[ui]* section of aerc.conf.
 
 	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..9ec562d 100644
--- a/lib/ui/textinput.go
+++ b/lib/ui/textinput.go
@@ -1,6 +1,8 @@
 package ui
 
 import (
+	"time"
+
 	"github.com/gdamore/tcell"
 	"github.com/mattn/go-runewidth"
 )
@@ -22,6 +24,9 @@ type TextInput struct {
 	tabcomplete   func(s string) []string
 	completions   []string
 	completeIndex int
+
+	completionDelay   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.completionDelay = d
 	return ti
 }
 
@@ -95,6 +101,10 @@ func (ti *TextInput) Draw(ctx *Context) {
 	cells := runewidth.StringWidth(string(text[:sindex]) + ti.prompt)
 	if ti.focus {
 		ctx.SetCursor(cells, 0)
+		if len(ti.completions) > 0 {
+			cmp := &completions{ti.completions, ti.completeIndex}
+			ctx.Popover(0, 0, maxLen(ti.completions)+2, len(ti.completions), cmp)
+		}
 	}
 }
 
@@ -221,9 +231,6 @@ func (ti *TextInput) nextCompletion() {
 			ti.completeIndex = 0
 		}
 	}
-	if len(ti.completions) > 0 {
-		ti.Set(ti.completions[ti.completeIndex] + ti.StringRight())
-	}
 }
 
 func (ti *TextInput) previousCompletion() {
@@ -234,6 +241,9 @@ func (ti *TextInput) previousCompletion() {
 	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 +254,25 @@ func (ti *TextInput) invalidateCompletions() {
 }
 
 func (ti *TextInput) onChange() {
+	ti.updateCompletions()
 	for _, change := range ti.change {
 		change(ti)
 	}
 }
 
+func (ti *TextInput) updateCompletions() {
+	if ti.completeDebouncer == nil {
+		ti.completeDebouncer = time.AfterFunc(ti.completionDelay, func() {
+			ti.completions = ti.tabcomplete(ti.StringLeft())
+			ti.completeIndex = 0
+			ti.Invalidate()
+		})
+	} else {
+		ti.completeDebouncer.Stop()
+		ti.completeDebouncer.Reset(ti.completionDelay)
+	}
+}
+
 func (ti *TextInput) OnChange(onChange func(ti *TextInput)) {
 	ti.change = append(ti.change, onChange)
 }
@@ -260,9 +284,11 @@ func (ti *TextInput) Event(event tcell.Event) bool {
 		case tcell.KeyBackspace, tcell.KeyBackspace2:
 			ti.invalidateCompletions()
 			ti.backspace()
+			return true
 		case tcell.KeyCtrlD, tcell.KeyDelete:
 			ti.invalidateCompletions()
 			ti.deleteChar()
+			return true
 		case tcell.KeyCtrlB, tcell.KeyLeft:
 			ti.invalidateCompletions()
 			if ti.index > 0 {
@@ -270,6 +296,7 @@ func (ti *TextInput) Event(event tcell.Event) bool {
 				ti.ensureScroll()
 				ti.Invalidate()
 			}
+			return true
 		case tcell.KeyCtrlF, tcell.KeyRight:
 			ti.invalidateCompletions()
 			if ti.index < len(ti.text) {
@@ -277,41 +304,94 @@ func (ti *TextInput) Event(event tcell.Event) bool {
 				ti.ensureScroll()
 				ti.Invalidate()
 			}
+			return true
 		case tcell.KeyCtrlA, tcell.KeyHome:
 			ti.invalidateCompletions()
 			ti.index = 0
 			ti.ensureScroll()
 			ti.Invalidate()
+			return true
 		case tcell.KeyCtrlE, tcell.KeyEnd:
 			ti.invalidateCompletions()
 			ti.index = len(ti.text)
 			ti.ensureScroll()
 			ti.Invalidate()
-		case tcell.KeyCtrlK:
-			ti.invalidateCompletions()
-			ti.deleteLineForward()
+			return true
 		case tcell.KeyCtrlW:
 			ti.invalidateCompletions()
 			ti.deleteWord()
+			return true
 		case tcell.KeyCtrlU:
 			ti.invalidateCompletions()
 			ti.deleteLineBackward()
-		case tcell.KeyTab:
-			if ti.tabcomplete != nil {
+			return true
+		case tcell.KeyTab, tcell.KeyCtrlN, tcell.KeyDown:
+			if ti.completions != nil {
 				ti.nextCompletion()
-			} else {
-				ti.insert('\t')
+				ti.Invalidate()
+				return true
 			}
-			ti.Invalidate()
-		case tcell.KeyBacktab:
-			if ti.tabcomplete != nil {
+		case tcell.KeyBacktab, tcell.KeyCtrlP, tcell.KeyUp:
+			if ti.completions != nil {
 				ti.previousCompletion()
+				ti.Invalidate()
+				return true
+			}
+		case tcell.KeyEnter:
+			if ti.completions != nil {
+				ti.executeCompletion()
+				ti.invalidateCompletions()
+				ti.Invalidate()
+				return true
+			}
+		case tcell.KeyESC:
+			if ti.completions != nil {
+				ti.invalidateCompletions()
+				ti.Invalidate()
+				return true
 			}
-			ti.Invalidate()
 		case tcell.KeyRune:
 			ti.invalidateCompletions()
 			ti.insert(event.Rune())
+			return true
+		}
+	}
+	return false
+}
+
+type completions struct {
+	options []string
+	idx     int
+}
+
+func maxLen(ss []string) int {
+	max := 0
+	for _, s := range ss {
+		l := runewidth.StringWidth(s)
+		if l > max {
+			max = l
 		}
 	}
-	return true
+	return max
 }
+
+func (c *completions) Draw(ctx *Context) {
+	bg := tcell.StyleDefault.Background(tcell.ColorDarkMagenta).Foreground(tcell.ColorBlack)
+	sel := tcell.StyleDefault.Background(tcell.ColorWhite).Foreground(tcell.ColorBlack)
+	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', bg)
+	ctx.Fill(0, c.idx, ctx.Width(), 1, ' ', sel)
+	for dy, opt := range c.options {
+		if dy >= ctx.Height() {
+			return
+		}
+		if c.idx == dy {
+			ctx.Printf(0, dy, sel, " %s ", opt)
+		} else {
+			ctx.Printf(0, dy, bg, " %s ", opt)
+		}
+	}
+}
+
+func (c *completions) Invalidate() {}
+
+func (c *completions) OnInvalidate(_ func(Drawable)) {}
diff --git a/widgets/aerc.go b/widgets/aerc.go
index 2897c90..6ba494a 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -400,7 +400,7 @@ func (aerc *Aerc) BeginExCommand(cmd string) {
 		aerc.focus(previous)
 	}, func(cmd string) []string {
 		return aerc.complete(cmd)
-	}, aerc.cmdHistory)
+	}, aerc.conf.Ui.CompletionDelay, aerc.cmdHistory)
 	aerc.statusbar.Push(exline)
 	aerc.focus(exline)
 }
@@ -417,7 +417,7 @@ func (aerc *Aerc) RegisterPrompt(prompt string, cmd []string) {
 		}
 	}, func(cmd string) []string {
 		return nil // TODO: completions
-	})
+	}, aerc.conf.Ui.CompletionDelay)
 	aerc.prompts.Push(p)
 }
 
diff --git a/widgets/exline.go b/widgets/exline.go
index d992ed5..e81e1c4 100644
--- a/widgets/exline.go
+++ b/widgets/exline.go
@@ -1,6 +1,8 @@
 package widgets
 
 import (
+	"time"
+
 	"github.com/gdamore/tcell"
 
 	"git.sr.ht/~sircmpwn/aerc/lib"
@@ -18,9 +20,10 @@ type ExLine struct {
 
 func NewExLine(cmd string, commit func(cmd string), finish func(),
 	tabcomplete func(cmd string) []string,
+	completeDelay time.Duration,
 	cmdHistory lib.History) *ExLine {
 
-	input := ui.NewTextInput("").Prompt(":").TabComplete(tabcomplete).Set(cmd)
+	input := ui.NewTextInput("").Prompt(":").TabComplete(tabcomplete, completeDelay).Set(cmd)
 	exline := &ExLine{
 		commit:      commit,
 		finish:      finish,
@@ -35,9 +38,9 @@ func NewExLine(cmd string, commit func(cmd string), finish func(),
 }
 
 func NewPrompt(prompt string, commit func(text string),
-	tabcomplete func(cmd string) []string) *ExLine {
+	tabcomplete func(cmd string) []string, completeDelay time.Duration) *ExLine {
 
-	input := ui.NewTextInput("").Prompt(prompt).TabComplete(tabcomplete)
+	input := ui.NewTextInput("").Prompt(prompt).TabComplete(tabcomplete, completeDelay)
 	exline := &ExLine{
 		commit:      commit,
 		tabcomplete: tabcomplete,
-- 
2.24.0

[PATCH v2 4/5] 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.0

[PATCH v2 5/5] 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 | 138 +++++++++++++++++++++++++++++++++++++++++
 config/aerc.conf.in    |  12 ++++
 config/config.go       |   5 +-
 doc/aerc-config.5.scd  |  16 +++++
 widgets/compose.go     |  22 ++++---
 5 files changed, 184 insertions(+), 9 deletions(-)
 create mode 100644 completer/completer.go

diff --git a/completer/completer.go b/completer/completer.go
new file mode 100644
index 0000000..4d1aafe
--- /dev/null
+++ b/completer/completer.go
@@ -0,0 +1,138 @@
+package completer
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"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)
+}
+
+// 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)) *Completer {
+	return &Completer{AddressBookCmd: addressBookCmd, errHandler: errHandler}
+}
+
+// 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) {
+		// 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, err
+	}
+	if err := cmd.Start(); err != nil {
+		return nil, err
+	}
+	completions, err := readCompletions(stdout)
+	if err != nil {
+		return nil, err
+	}
+	if err := cmd.Wait(); err != nil {
+		return nil, 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 91523c4..af1f9df 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -118,6 +118,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 f0342d8..ddeb34e 100644
--- a/config/config.go
+++ b/config/config.go
@@ -77,8 +77,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 af047da..d8186e9 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -213,6 +213,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 62aaafe..db3476f 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"
@@ -43,6 +44,7 @@ type Composer struct {
 	header      *ui.Grid
 	review      *reviewMessage
 	worker      *types.Worker
+	completer   *completer.Completer
 
 	layout    HeaderLayout
 	focusable []ui.MouseableDrawableInteractive
@@ -65,8 +67,11 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
 	}
 
 	templateData := templates.ParseTemplateData(defaults)
+	cmpl := completer.New(conf.Compose.AddressBookCmd, func(err error) {
+		worker.Logger.Printf("could not complete header: %v", err)
+	})
 	layout, editors, focusable := buildComposeHeader(
-		conf.Compose.HeaderLayout, defaults)
+		conf.Compose.HeaderLayout, cmpl, conf.Ui.CompletionDelay, defaults)
 
 	email, err := ioutil.TempFile("", "aerc-compose-*.eml")
 	if err != nil {
@@ -86,6 +91,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()
@@ -99,7 +105,8 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
 	return c, nil
 }
 
-func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
+func buildComposeHeader(layout HeaderLayout, cmpl *completer.Completer,
+	completionDelay time.Duration, defaults map[string]string) (
 	newLayout HeaderLayout,
 	editors map[string]*headerEditor,
 	focusable []ui.MouseableDrawableInteractive,
@@ -109,7 +116,7 @@ func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
 
 	for _, row := range layout {
 		for _, h := range row {
-			e := newHeaderEditor(h, "")
+			e := newHeaderEditor(h, "", cmpl.ForHeader(h), completionDelay)
 			editors[h] = e
 			switch h {
 			case "From":
@@ -125,7 +132,7 @@ func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
 	for _, h := range []string{"Cc", "Bcc"} {
 		if val, ok := defaults[h]; ok && val != "" {
 			if _, ok := editors[h]; !ok {
-				e := newHeaderEditor(h, "")
+				e := newHeaderEditor(h, "", cmpl.ForHeader(h), completionDelay)
 				editors[h] = e
 				focusable = append(focusable, e)
 				layout = append(layout, []string{h})
@@ -672,7 +679,7 @@ func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
 		}
 		return
 	}
-	e := newHeaderEditor(header, value)
+	e := newHeaderEditor(header, value, 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
@@ -726,9 +733,10 @@ type headerEditor struct {
 	input   *ui.TextInput
 }
 
-func newHeaderEditor(name string, value string) *headerEditor {
+func newHeaderEditor(name string, value string,
+	complete func(string) []string, delay time.Duration) *headerEditor {
 	return &headerEditor{
-		input: ui.NewTextInput(value),
+		input: ui.NewTextInput(value).TabComplete(complete, delay),
 		name:  name,
 	}
 }
-- 
2.24.0
View this thread in the archives