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(-)
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 -3Learn more about email & git
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
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
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
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