Skip to content

Commit

Permalink
Support suggestions and autocompletion in textinput (#407)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
toadle and maaslalani authored Aug 21, 2023
1 parent 95d7be5 commit eda8912
Showing 1 changed file with 145 additions and 7 deletions.
152 changes: 145 additions & 7 deletions textinput/textinput.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package textinput

import (
"reflect"
"strings"
"time"
"unicode"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

0 comments on commit eda8912

Please sign in to comment.