From c906d1904dcde065ceee1024de756b306e63ba68 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Wed, 6 Jul 2022 12:07:18 -0400 Subject: [PATCH] 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 ``` --- search/command.go | 49 +++++++++++++++++++ search/options.go | 10 ++++ search/search.go | 120 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 search/command.go create mode 100644 search/options.go create mode 100644 search/search.go diff --git a/search/command.go b/search/command.go new file mode 100644 index 0000000..ad00812 --- /dev/null +++ b/search/command.go @@ -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) + } +} diff --git a/search/options.go b/search/options.go new file mode 100644 index 0000000..7cea70a --- /dev/null +++ b/search/options.go @@ -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"` +} diff --git a/search/search.go b/search/search.go new file mode 100644 index 0000000..a9ad230 --- /dev/null +++ b/search/search.go @@ -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 +}