diff --git a/textinput/textinput.go b/textinput/textinput.go index f8990f6..82341b9 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -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 + } +}