diff options
| author | Ben Burwell <ben@benburwell.com> | 2019-12-20 13:21:33 -0500 | 
|---|---|---|
| committer | Drew DeVault <sir@cmpwn.com> | 2019-12-21 09:23:21 -0500 | 
| commit | 7160f98a9081bcab05904484eae790ec0a006b87 (patch) | |
| tree | e35712afd3dcec12efd47a89d8e4f652fab9cca1 | |
| parent | bcd03c4c4a94e73b2545bf5dfc404082a674c76e (diff) | |
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.
| -rw-r--r-- | config/aerc.conf.in | 11 | ||||
| -rw-r--r-- | config/config.go | 35 | ||||
| -rw-r--r-- | doc/aerc-config.5.scd | 10 | ||||
| -rw-r--r-- | lib/ui/textinput.go | 273 | ||||
| -rw-r--r-- | widgets/aerc.go | 4 | ||||
| -rw-r--r-- | 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,  | 
