From 7160f98a9081bcab05904484eae790ec0a006b87 Mon Sep 17 00:00:00 2001 From: Ben Burwell Date: Fri, 20 Dec 2019 13:21:33 -0500 Subject: [PATCH] Show textinput completions in popovers 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.KeyESC: + if ti.completions != nil { + ti.invalidateCompletions() + ti.Invalidate() + } case tcell.KeyTab: - if ti.tabcomplete != nil { - ti.nextCompletion() - } else { - ti.insert('\t') - } - ti.Invalidate() - case tcell.KeyBacktab: - if ti.tabcomplete != nil { - ti.previousCompletion() - } - ti.Invalidate() + 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,