diff --git a/textarea/textarea.go b/textarea/textarea.go index 58446a0..d1a0040 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -3,6 +3,7 @@ package textarea import ( "crypto/sha256" "fmt" + "reflect" "strconv" "strings" "unicode" @@ -64,6 +65,10 @@ type KeyMap struct { CapitalizeWordForward key.Binding TransposeCharacterBackward key.Binding + + AcceptSuggestion key.Binding + NextSuggestion key.Binding + PrevSuggestion key.Binding } // DefaultKeyMap returns the default set of key bindings for navigating and acting @@ -94,6 +99,10 @@ func DefaultKeyMap() KeyMap { UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), + + AcceptSuggestion: key.NewBinding(key.WithKeys("tab")), + NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")), + PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")), } } @@ -248,6 +257,15 @@ type Model struct { SyntaxHighlighter func(string) string + // 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 + // If promptFunc is set, it replaces Prompt as a generator for // prompt strings at the beginning of each line. promptFunc func(line int) string @@ -310,10 +328,11 @@ func New() Model { Cursor: cur, KeyMap: DefaultKeyMap(), - value: make([][]rune, minHeight, maxLines), - focus: false, - col: 0, - row: 0, + suggestions: [][][]rune{}, + value: make([][]rune, minHeight, maxLines), + focus: false, + col: 0, + row: 0, viewport: &vp, } @@ -369,6 +388,18 @@ func (m *Model) SetValue(s string) { m.InsertString(s) } +// SetSuggestions sets the suggestions for the input. +func (m *Model) SetSuggestions(suggestions []string) { + m.suggestions = make([][][]rune, len(suggestions)) + for i, s := range suggestions { + for _, line := range strings.Split(s, "\n") { + m.suggestions[i] = append(m.suggestions[i], []rune(line)) + } + } + + m.updateSuggestions() +} + // InsertString inserts a string at the cursor position. func (m *Model) InsertString(s string) { m.insertRunesFromUserInput([]rune(s)) @@ -982,6 +1013,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() + } + } + // Used to determine if the cursor should blink. oldRow, oldCol := m.cursorLineNumber(), m.col @@ -1083,11 +1123,19 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.capitalizeRight() case key.Matches(msg, m.KeyMap.TransposeCharacterBackward): m.transposeLeft() + case key.Matches(msg, m.KeyMap.NextSuggestion): + m.nextSuggestion() + case key.Matches(msg, m.KeyMap.PrevSuggestion): + m.previousSuggestion() default: m.insertRunesFromUserInput([]rune(msg.Text)) } + // 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)) @@ -1194,10 +1242,12 @@ func (m Model) View() string { if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.Cursor.SetChar(" ") s.WriteString(m.Cursor.View()) + // XXX: suggestions } else { m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) s.WriteString(style.Render(m.Cursor.View())) s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) + // XXX: suggestions } } else { ln := string(wrappedLine) @@ -1425,6 +1475,62 @@ func (m *Model) splitLine(row, col int) { m.row++ } +// canAcceptSuggestion returns whether there is an acceptable suggestion to +// autocomplete the current value. +func (m *Model) canAcceptSuggestion() bool { + return len(m.matchedSuggestions) > 0 +} + +func linesToString(lines [][]rune) string { + var result []string + for _, line := range lines { + result = append(result, string(line)) + } + return strings.Join(result, "\n") +} + +// 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 := linesToString(s) + + if strings.HasPrefix(strings.ToLower(suggestion), strings.ToLower(linesToString(m.value))) { + matches = append(matches, s) + } + } + 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 + } +} + // Paste is a command for pasting from the clipboard into the text input. func Paste() tea.Msg { str, err := clipboard.ReadAll()