Support suggestions and autocompletion in textinput (#407)

* migrate all autocomplete-work from old branch

* added support for multiple suggestions

* fix linter issues

* refactored to only offer matching suggestions for completion

* fix: SetSuggestions + Suggestions

* make: configuration behaviour configurable

* fix for double-width runes

* refactored all suggestions to be rune-arrays

also: accepting suggestions does not overwrite the already given input, rather appends

* fix: make suggestions and OnAcceptSuggestions unexported

* refactor: refreshingMatchingSuggestions -> updateSuggestions

---------

Co-authored-by: Maas Lalani <maas@lalani.dev>
This commit is contained in:
Tim Adler
2023-08-21 11:26:02 -04:00
committed by GitHub
co-authored by Maas Lalani
parent 95d7be54d9
commit eda891258c
+145 -7
View File
@@ -1,6 +1,7 @@
package textinput
import (
"reflect"
"strings"
"time"
"unicode"
@@ -32,8 +33,6 @@ const (
// EchoNone displays nothing as characters are entered. This is commonly
// seen for password fields on the command line.
EchoNone
// EchoOnEdit.
)
// ValidateFunc is a function that returns an error if the input is invalid.
@@ -54,6 +53,9 @@ type KeyMap struct {
LineStart key.Binding
LineEnd key.Binding
Paste key.Binding
AcceptSuggestion key.Binding
NextSuggestion key.Binding
PrevSuggestion key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
@@ -72,6 +74,9 @@ var DefaultKeyMap = KeyMap{
LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")),
LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")),
Paste: key.NewBinding(key.WithKeys("ctrl+v")),
AcceptSuggestion: key.NewBinding(key.WithKeys("tab")),
NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")),
PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")),
}
// Model is the Bubble Tea model for this text input element.
@@ -95,6 +100,7 @@ type Model struct {
PromptStyle lipgloss.Style
TextStyle lipgloss.Style
PlaceholderStyle lipgloss.Style
CompletionStyle lipgloss.Style
// Deprecated: use Cursor.Style instead.
CursorStyle lipgloss.Style
@@ -134,6 +140,15 @@ type Model struct {
// rune sanitizer for input.
rsan runeutil.Sanitizer
// Should the input suggest to complete
ShowSuggestions bool
// suggestions is a list of suggestions that may be used to complete the
// input.
suggestions [][]rune
matchedSuggestions [][]rune
currentSuggestionIndex int
}
// New creates a new model with default settings.
@@ -143,12 +158,15 @@ func New() Model {
EchoCharacter: '*',
CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
ShowSuggestions: false,
CompletionStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Cursor: cursor.New(),
KeyMap: DefaultKeyMap,
value: nil,
focus: false,
pos: 0,
suggestions: [][]rune{},
value: nil,
focus: false,
pos: 0,
}
}
@@ -239,6 +257,17 @@ func (m *Model) Reset() {
m.SetCursor(0)
}
// SetSuggestions sets the suggestions for the input.
func (m *Model) SetSuggestions(suggestions []string) {
m.suggestions = [][]rune{}
for _, s := range suggestions {
m.suggestions = append(m.suggestions, []rune(s))
}
m.updateSuggestions()
}
// rsan initializes or retrieves the rune sanitizer.
func (m *Model) san() runeutil.Sanitizer {
if m.rsan == nil {
@@ -529,6 +558,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}
// Need to check for completion before, because key is configurable and might be double assigned
keyMsg, ok := msg.(tea.KeyMsg)
if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) {
if m.canAcceptSuggestion() {
m.value = append(m.value, m.matchedSuggestions[m.currentSuggestionIndex][len(m.value):]...)
m.CursorEnd()
}
}
// Let's remember where the position of the cursor currently is so that if
// the cursor position changes, we can reset the blink.
oldPos := m.pos //nolint
@@ -577,11 +615,19 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, Paste
case key.Matches(msg, m.KeyMap.DeleteWordForward):
m.deleteWordForward()
case key.Matches(msg, m.KeyMap.NextSuggestion):
m.nextSuggestion()
case key.Matches(msg, m.KeyMap.PrevSuggestion):
m.previousSuggestion()
default:
// Input one or more regular characters.
m.insertRunesFromUserInput(msg.Runes)
}
// Check again if can be completed
// because value might be something that does not match the completion prefix
m.updateSuggestions()
case pasteMsg:
m.insertRunesFromUserInput([]rune(msg))
@@ -622,9 +668,23 @@ func (m Model) View() string {
m.Cursor.SetChar(char)
v += m.Cursor.View() // cursor and text under it
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
v += m.completionView(0) // suggested completion
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
if m.canAcceptSuggestion() {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
m.Cursor.TextStyle = m.CompletionStyle
m.Cursor.SetChar(m.echoTransform(string(suggestion[pos])))
v += m.Cursor.View()
v += m.completionView(1)
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
}
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
}
}
// If a max width and background color were set fill the empty spaces with
@@ -721,3 +781,81 @@ func (m Model) CursorMode() CursorMode {
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
return m.Cursor.SetMode(cursor.Mode(mode))
}
func (m Model) completionView(offset int) string {
var (
value = m.value
style = m.PlaceholderStyle.Inline(true).Render
)
if m.canAcceptSuggestion() {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
return style(string(suggestion[len(value)+offset:]))
}
}
return ""
}
// AvailableSuggestions returns the list of available suggestions.
func (m *Model) AvailableSuggestions() []string {
suggestions := []string{}
for _, s := range m.suggestions {
suggestions = append(suggestions, string(s))
}
return suggestions
}
// CurrentSuggestion returns the currently selected suggestion.
func (m *Model) CurrentSuggestion() string {
return string(m.matchedSuggestions[m.currentSuggestionIndex])
}
// canAcceptSuggestion returns whether there is an acceptable suggestion to
// autocomplete the current value.
func (m *Model) canAcceptSuggestion() bool {
return len(m.matchedSuggestions) > 0
}
// updateSuggestions refreshes the list of matching suggestions.
func (m *Model) updateSuggestions() {
if !m.ShowSuggestions {
return
}
if len(m.value) <= 0 || len(m.suggestions) <= 0 {
m.matchedSuggestions = [][]rune{}
return
}
matches := [][]rune{}
for _, s := range m.suggestions {
suggestion := string(s)
if strings.HasPrefix(strings.ToLower(suggestion), strings.ToLower(string(m.value))) {
matches = append(matches, []rune(suggestion))
}
}
if !reflect.DeepEqual(matches, m.matchedSuggestions) {
m.currentSuggestionIndex = 0
}
m.matchedSuggestions = matches
}
// nextSuggestion selects the next suggestion.
func (m *Model) nextSuggestion() {
m.currentSuggestionIndex = (m.currentSuggestionIndex + 1)
if m.currentSuggestionIndex >= len(m.matchedSuggestions) {
m.currentSuggestionIndex = 0
}
}
// previousSuggestion selects the previous suggestion.
func (m *Model) previousSuggestion() {
m.currentSuggestionIndex = (m.currentSuggestionIndex - 1)
if m.currentSuggestionIndex < 0 {
m.currentSuggestionIndex = len(m.matchedSuggestions) - 1
}
}