mirror of
https://github.com/charmbracelet/gum
synced 2024-06-01 21:32:15 +02:00
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:
parent
2f07eacf50
commit
c906d1904d
49
search/command.go
Normal file
49
search/command.go
Normal 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
10
search/options.go
Normal 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
120
search/search.go
Normal 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
|
||||||
|
}
|
Loading…
Reference in a new issue