mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-16 01:43:21 +00:00
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:
+145
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user