feat: Add gum search command

Search provides a fuzzy searching text input to allow filtering a list
of options to select one option.

i.e. Let's pick from a list of gum flavors:

```
cat flavors.text | gum search
```
This commit is contained in:
Maas Lalani 2022-07-06 12:07:18 -04:00
parent 2f07eacf50
commit c906d1904d
No known key found for this signature in database
GPG key ID: 5A6ED5CBF1A0A000
3 changed files with 179 additions and 0 deletions

49
search/command.go Normal file
View file

@ -0,0 +1,49 @@
package search
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/sodapop/internal/log"
"github.com/charmbracelet/sodapop/internal/stdin"
"github.com/muesli/termenv"
)
// Run provides a shell script interface for the search bubble.
// https://github.com/charmbracelet/bubbles/search
func (o Options) Run() {
lipgloss.SetColorProfile(termenv.ANSI256)
i := textinput.New()
i.Focus()
i.Prompt = o.Prompt
i.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(o.AccentColor))
i.Placeholder = o.Placeholder
i.Width = o.Width
input, err := stdin.Read()
if err != nil || input == "" {
log.Error("No input provided.")
return
}
choices := strings.Split(string(input), "\n")
p := tea.NewProgram(model{
textinput: i,
choices: choices,
matches: matchAll(choices),
indicator: o.Indicator,
}, tea.WithOutput(os.Stderr))
tm, _ := p.StartReturningModel()
m := tm.(model)
if len(m.matches) > m.selected && m.selected >= 0 {
fmt.Println(m.matches[m.selected].Str)
}
}

10
search/options.go Normal file
View file

@ -0,0 +1,10 @@
package search
// Options is the customization options for the search.
type Options struct {
AccentColor string `help:"Accent color for prompt, indicator, and matches" default:"#FF06B7"`
Indicator string `help:"Character for selection" default:"•"`
Placeholder string `help:"Placeholder value" default:"Search..."`
Prompt string `help:"Prompt to display" default:"> "`
Width int `help:"Input width" default:"20"`
}

120
search/search.go Normal file
View file

@ -0,0 +1,120 @@
package search
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/sahilm/fuzzy"
)
type model struct {
textinput textinput.Model
choices []string
matches []fuzzy.Match
selected int
indicator string
height int
}
func (m model) Init() tea.Cmd { return nil }
func (m model) View() string {
var s strings.Builder
// Since there are matches, display them so that the user can see, in real
// time, what they are searching for.
for i, match := range m.matches {
// If this is the current selected index, we add a small indicator to
// represent it. Otherwise, simply pad the string.
if i == m.selected {
s.WriteString(m.textinput.PromptStyle.Render(m.indicator) + " ")
} else {
s.WriteString(strings.Repeat(" ", runewidth.StringWidth(m.indicator)) + " ")
}
// For this match, there are a certain number of characters that have
// caused the match. i.e. fuzzy matching.
// We should indicate to the users which characters are being matched.
var mi = 0
for ci, c := range match.Str {
// Check if the current character index matches the current matched
// index. If so, color the character to indicate a match.
if mi < len(match.MatchedIndexes) && ci == match.MatchedIndexes[mi] {
s.WriteString(m.textinput.PromptStyle.Render(string(c)))
// We have matched this character, so we never have to check it
// again. Move on to the next match.
mi++
} else {
// Not a match, simply show the character, unstyled.
s.WriteRune(c)
}
}
// We have finished displaying the match with all of it's matched
// characters highlighted and the rest filled in.
// Move on to the next match.
s.WriteRune('\n')
}
tv := m.textinput.View()
results := lipgloss.NewStyle().MaxHeight(m.height - lipgloss.Height(tv)).Render(s.String())
// View the input and the filtered choices
return tv + "\n" + results
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "enter":
return m, tea.Quit
case "ctrl+n":
m.selected = clamp(0, len(m.matches)-1, m.selected+1)
case "ctrl+p":
m.selected = clamp(0, len(m.matches)-1, m.selected-1)
default:
m.textinput, cmd = m.textinput.Update(msg)
// A character was entered, this likely means that the text input
// has changed. This suggests that the matches are outdated, so
// update them, with a fuzzy finding algorithm provided by
// https://github.com/sahilm/fuzzy
m.matches = fuzzy.Find(m.textinput.Value(), m.choices)
// If the search field is empty, let's not display the matches
// (none), but rather display all possible choices.
if m.textinput.Value() == "" {
m.matches = matchAll(m.choices)
}
}
}
// It's possible that filtering items have caused fewer matches. So, ensure
// that the selected index is within the bounds of the number of matches.
m.selected = clamp(0, len(m.matches)-1, m.selected)
return m, cmd
}
func matchAll(options []string) []fuzzy.Match {
var matches []fuzzy.Match
for _, option := range options {
matches = append(matches, fuzzy.Match{Str: option})
}
return matches
}
func clamp(min, max, val int) int {
if val < min {
return min
}
if val > max {
return max
}
return val
}