mirror of
https://github.com/charmbracelet/gum
synced 2024-05-17 21:56:42 +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