Skip to content

Commit eda8912

Browse files
toadlemaaslalani
andauthored
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 <[email protected]>
1 parent 95d7be5 commit eda8912

File tree

1 file changed

+145
-7
lines changed

1 file changed

+145
-7
lines changed

textinput/textinput.go

Lines changed: 145 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package textinput
22

33
import (
4+
"reflect"
45
"strings"
56
"time"
67
"unicode"
@@ -32,8 +33,6 @@ const (
3233
// EchoNone displays nothing as characters are entered. This is commonly
3334
// seen for password fields on the command line.
3435
EchoNone
35-
36-
// EchoOnEdit.
3736
)
3837

3938
// ValidateFunc is a function that returns an error if the input is invalid.
@@ -54,6 +53,9 @@ type KeyMap struct {
5453
LineStart key.Binding
5554
LineEnd key.Binding
5655
Paste key.Binding
56+
AcceptSuggestion key.Binding
57+
NextSuggestion key.Binding
58+
PrevSuggestion key.Binding
5759
}
5860

5961
// DefaultKeyMap is the default set of key bindings for navigating and acting
@@ -72,6 +74,9 @@ var DefaultKeyMap = KeyMap{
7274
LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")),
7375
LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")),
7476
Paste: key.NewBinding(key.WithKeys("ctrl+v")),
77+
AcceptSuggestion: key.NewBinding(key.WithKeys("tab")),
78+
NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")),
79+
PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")),
7580
}
7681

7782
// Model is the Bubble Tea model for this text input element.
@@ -95,6 +100,7 @@ type Model struct {
95100
PromptStyle lipgloss.Style
96101
TextStyle lipgloss.Style
97102
PlaceholderStyle lipgloss.Style
103+
CompletionStyle lipgloss.Style
98104

99105
// Deprecated: use Cursor.Style instead.
100106
CursorStyle lipgloss.Style
@@ -134,6 +140,15 @@ type Model struct {
134140

135141
// rune sanitizer for input.
136142
rsan runeutil.Sanitizer
143+
144+
// Should the input suggest to complete
145+
ShowSuggestions bool
146+
147+
// suggestions is a list of suggestions that may be used to complete the
148+
// input.
149+
suggestions [][]rune
150+
matchedSuggestions [][]rune
151+
currentSuggestionIndex int
137152
}
138153

139154
// New creates a new model with default settings.
@@ -143,12 +158,15 @@ func New() Model {
143158
EchoCharacter: '*',
144159
CharLimit: 0,
145160
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
161+
ShowSuggestions: false,
162+
CompletionStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
146163
Cursor: cursor.New(),
147164
KeyMap: DefaultKeyMap,
148165

149-
value: nil,
150-
focus: false,
151-
pos: 0,
166+
suggestions: [][]rune{},
167+
value: nil,
168+
focus: false,
169+
pos: 0,
152170
}
153171
}
154172

@@ -239,6 +257,17 @@ func (m *Model) Reset() {
239257
m.SetCursor(0)
240258
}
241259

260+
// SetSuggestions sets the suggestions for the input.
261+
func (m *Model) SetSuggestions(suggestions []string) {
262+
m.suggestions = [][]rune{}
263+
264+
for _, s := range suggestions {
265+
m.suggestions = append(m.suggestions, []rune(s))
266+
}
267+
268+
m.updateSuggestions()
269+
}
270+
242271
// rsan initializes or retrieves the rune sanitizer.
243272
func (m *Model) san() runeutil.Sanitizer {
244273
if m.rsan == nil {
@@ -529,6 +558,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
529558
return m, nil
530559
}
531560

561+
// Need to check for completion before, because key is configurable and might be double assigned
562+
keyMsg, ok := msg.(tea.KeyMsg)
563+
if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) {
564+
if m.canAcceptSuggestion() {
565+
m.value = append(m.value, m.matchedSuggestions[m.currentSuggestionIndex][len(m.value):]...)
566+
m.CursorEnd()
567+
}
568+
}
569+
532570
// Let's remember where the position of the cursor currently is so that if
533571
// the cursor position changes, we can reset the blink.
534572
oldPos := m.pos //nolint
@@ -577,11 +615,19 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
577615
return m, Paste
578616
case key.Matches(msg, m.KeyMap.DeleteWordForward):
579617
m.deleteWordForward()
618+
case key.Matches(msg, m.KeyMap.NextSuggestion):
619+
m.nextSuggestion()
620+
case key.Matches(msg, m.KeyMap.PrevSuggestion):
621+
m.previousSuggestion()
580622
default:
581623
// Input one or more regular characters.
582624
m.insertRunesFromUserInput(msg.Runes)
583625
}
584626

627+
// Check again if can be completed
628+
// because value might be something that does not match the completion prefix
629+
m.updateSuggestions()
630+
585631
case pasteMsg:
586632
m.insertRunesFromUserInput([]rune(msg))
587633

@@ -622,9 +668,23 @@ func (m Model) View() string {
622668
m.Cursor.SetChar(char)
623669
v += m.Cursor.View() // cursor and text under it
624670
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
671+
v += m.completionView(0) // suggested completion
625672
} else {
626-
m.Cursor.SetChar(" ")
627-
v += m.Cursor.View()
673+
if m.canAcceptSuggestion() {
674+
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
675+
if len(value) < len(suggestion) {
676+
m.Cursor.TextStyle = m.CompletionStyle
677+
m.Cursor.SetChar(m.echoTransform(string(suggestion[pos])))
678+
v += m.Cursor.View()
679+
v += m.completionView(1)
680+
} else {
681+
m.Cursor.SetChar(" ")
682+
v += m.Cursor.View()
683+
}
684+
} else {
685+
m.Cursor.SetChar(" ")
686+
v += m.Cursor.View()
687+
}
628688
}
629689

630690
// If a max width and background color were set fill the empty spaces with
@@ -721,3 +781,81 @@ func (m Model) CursorMode() CursorMode {
721781
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
722782
return m.Cursor.SetMode(cursor.Mode(mode))
723783
}
784+
785+
func (m Model) completionView(offset int) string {
786+
var (
787+
value = m.value
788+
style = m.PlaceholderStyle.Inline(true).Render
789+
)
790+
791+
if m.canAcceptSuggestion() {
792+
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
793+
if len(value) < len(suggestion) {
794+
return style(string(suggestion[len(value)+offset:]))
795+
}
796+
}
797+
return ""
798+
}
799+
800+
// AvailableSuggestions returns the list of available suggestions.
801+
func (m *Model) AvailableSuggestions() []string {
802+
suggestions := []string{}
803+
for _, s := range m.suggestions {
804+
suggestions = append(suggestions, string(s))
805+
}
806+
807+
return suggestions
808+
}
809+
810+
// CurrentSuggestion returns the currently selected suggestion.
811+
func (m *Model) CurrentSuggestion() string {
812+
return string(m.matchedSuggestions[m.currentSuggestionIndex])
813+
}
814+
815+
// canAcceptSuggestion returns whether there is an acceptable suggestion to
816+
// autocomplete the current value.
817+
func (m *Model) canAcceptSuggestion() bool {
818+
return len(m.matchedSuggestions) > 0
819+
}
820+
821+
// updateSuggestions refreshes the list of matching suggestions.
822+
func (m *Model) updateSuggestions() {
823+
if !m.ShowSuggestions {
824+
return
825+
}
826+
827+
if len(m.value) <= 0 || len(m.suggestions) <= 0 {
828+
m.matchedSuggestions = [][]rune{}
829+
return
830+
}
831+
832+
matches := [][]rune{}
833+
for _, s := range m.suggestions {
834+
suggestion := string(s)
835+
836+
if strings.HasPrefix(strings.ToLower(suggestion), strings.ToLower(string(m.value))) {
837+
matches = append(matches, []rune(suggestion))
838+
}
839+
}
840+
if !reflect.DeepEqual(matches, m.matchedSuggestions) {
841+
m.currentSuggestionIndex = 0
842+
}
843+
844+
m.matchedSuggestions = matches
845+
}
846+
847+
// nextSuggestion selects the next suggestion.
848+
func (m *Model) nextSuggestion() {
849+
m.currentSuggestionIndex = (m.currentSuggestionIndex + 1)
850+
if m.currentSuggestionIndex >= len(m.matchedSuggestions) {
851+
m.currentSuggestionIndex = 0
852+
}
853+
}
854+
855+
// previousSuggestion selects the previous suggestion.
856+
func (m *Model) previousSuggestion() {
857+
m.currentSuggestionIndex = (m.currentSuggestionIndex - 1)
858+
if m.currentSuggestionIndex < 0 {
859+
m.currentSuggestionIndex = len(m.matchedSuggestions) - 1
860+
}
861+
}

0 commit comments

Comments
 (0)