diff --git a/assets/img/MacOSAndLinux.gif b/assets/img/MacOSAndLinux.gif index a1cbc0a..f115931 100644 Binary files a/assets/img/MacOSAndLinux.gif and b/assets/img/MacOSAndLinux.gif differ diff --git a/assets/img/Sail.gif b/assets/img/Sail.gif index c2a0a14..535ded6 100644 Binary files a/assets/img/Sail.gif and b/assets/img/Sail.gif differ diff --git a/assets/img/configure-oauth.gif b/assets/img/configure-oauth.gif index be05e69..323f49c 100644 Binary files a/assets/img/configure-oauth.gif and b/assets/img/configure-oauth.gif differ diff --git a/assets/img/configure-pat.gif b/assets/img/configure-pat.gif index 7e072a0..060c8d6 100644 Binary files a/assets/img/configure-pat.gif and b/assets/img/configure-pat.gif differ diff --git a/assets/vhs/configure-oauth.tape b/assets/vhs/configure-oauth.tape index b494528..b040884 100644 --- a/assets/vhs/configure-oauth.tape +++ b/assets/vhs/configure-oauth.tape @@ -4,7 +4,7 @@ Output assets/img/configure-oauth.gif # Set up a 1200x600 terminal with 46px font. Set FontSize 16 Set Width 1200 -Set Height 300 +Set Height 500 Hide Sleep 2s Show @@ -14,7 +14,19 @@ Hide Sleep 2s Show Sleep 300ms # Type a command in the terminal. -Type "sail configure oauth" +Type "sail configure" + +# Pause for dramatic effect... +Sleep 1s + +# Run the command by pressing enter. +Enter + +# Pause for dramatic effect... +Sleep 1s + +# Type a command in the terminal. +Down # Pause for dramatic effect... Sleep 1s diff --git a/assets/vhs/configure-pat.tape b/assets/vhs/configure-pat.tape index 013e4b0..0bf9b22 100644 --- a/assets/vhs/configure-pat.tape +++ b/assets/vhs/configure-pat.tape @@ -4,7 +4,7 @@ Output assets/img/configure-pat.gif # Set up a 1200x600 terminal with 46px font. Set FontSize 16 Set Width 1200 -Set Height 300 +Set Height 500 Hide Sleep 2s Show @@ -14,7 +14,7 @@ Hide Sleep 2s Show Sleep 300ms # Type a command in the terminal. -Type "sail configure pat" +Type "sail configure" # Pause for dramatic effect... Sleep 1s diff --git a/cmd/root/configure.go b/cmd/root/configure.go index f267ac0..82ba767 100644 --- a/cmd/root/configure.go +++ b/cmd/root/configure.go @@ -10,8 +10,11 @@ import ( "strconv" "strings" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" "github.com/sailpoint-oss/sailpoint-cli/internal/auth" "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/types" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -27,6 +30,33 @@ const ( configYamlFile = "config.yaml" ) +func PromptAuth() (string, error) { + items := []list.Item{ + tui.Item("PAT"), + tui.Item("OAuth"), + } + + const defaultWidth = 20 + + l := list.New(items, tui.ItemDelegate{}, defaultWidth, tui.ListHeight) + l.Title = "What authentication method do you want to use?" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = tui.TitleStyle + l.Styles.PaginationStyle = tui.PaginationStyle + l.Styles.HelpStyle = tui.HelpStyle + + m := tui.Model{List: l} + _, err := tea.NewProgram(m).Run() + if err != nil { + return "", err + } + + choice := m.Retrieve() + + return choice, nil +} + func newConfigureCmd(client client.Client) *cobra.Command { var debug bool cmd := &cobra.Command{ @@ -38,9 +68,15 @@ func newConfigureCmd(client client.Client) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { var AuthType string + var err error if len(args) > 0 { AuthType = args[0] + } else { + AuthType, err = PromptAuth() + if err != nil { + return err + } } config, err := getConfigureParamsFromStdin(AuthType, debug) diff --git a/go.mod b/go.mod index 0f5e5a3..20e3a64 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/alessio/shellescape v1.4.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52 v1.0.3 // indirect github.com/containerd/console v1.0.3 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect @@ -43,7 +44,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.13.0 // indirect @@ -51,6 +52,7 @@ require ( github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/rivo/uniseg v0.4.3 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect github.com/spf13/afero v1.9.3 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect diff --git a/go.sum b/go.sum index 82726ea..6865df2 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,7 @@ github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpH github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= @@ -167,6 +168,7 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= @@ -191,8 +193,9 @@ github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWV github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= @@ -225,6 +228,7 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= diff --git a/internal/model/model.go b/internal/model/model.go deleted file mode 100644 index 96ae869..0000000 --- a/internal/model/model.go +++ /dev/null @@ -1,75 +0,0 @@ -package model - -import ( - "fmt" - "os" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type errMsg error - -type model struct { - spinner spinner.Model - quitting bool - err error -} - -var quitKeys = key.NewBinding( - key.WithKeys("q", "esc", "ctrl+c"), - key.WithHelp("", "press q to quit"), -) - -func initialModel() model { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - return model{spinner: s} -} - -func (m model) Init() tea.Cmd { - return m.spinner.Tick -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - - case tea.KeyMsg: - if key.Matches(msg, quitKeys) { - m.quitting = true - return m, tea.Quit - - } - return m, nil - case errMsg: - m.err = msg - return m, nil - - default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } -} - -func (m model) View() string { - if m.err != nil { - return m.err.Error() - } - str := fmt.Sprintf("\n\n %s Loading forever... %s\n\n", m.spinner.View(), quitKeys.Help().Desc) - if m.quitting { - return str + "\n" - } - return str -} - -func main() { - p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { - fmt.Println(err) - os.Exit(1) - } -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..5cd22c1 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,99 @@ +package tui + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ListHeight = 14 + +var ( + TitleStyle = lipgloss.NewStyle().MarginLeft(2) + itemStyle = lipgloss.NewStyle().PaddingLeft(4) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) + PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) + quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) +) + +type Item string + +func (i Item) FilterValue() string { return "" } + +type ItemDelegate struct{} + +func (d ItemDelegate) Height() int { return 1 } +func (d ItemDelegate) Spacing() int { return 0 } +func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(Item) + if !ok { + return + } + + str := fmt.Sprintf("%d. %s", index+1, i) + + fn := itemStyle.Render + if index == m.Index() { + fn = func(s string) string { + return selectedItemStyle.Render("> " + s) + } + } + + fmt.Fprint(w, fn(str)) +} + +type Model struct { + List list.Model + Quitting bool +} + +func (m Model) Init() tea.Cmd { + return nil +} + +var choice string + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.List.SetWidth(msg.Width) + return m, nil + + case tea.KeyMsg: + switch keypress := msg.String(); keypress { + case "ctrl+c": + m.Quitting = true + return m, tea.Quit + + case "enter": + i, ok := m.List.SelectedItem().(Item) + if ok { + choice = string(i) + } + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.List, cmd = m.List.Update(msg) + return m, cmd +} + +func (m Model) View() string { + if choice != "" { + return quitTextStyle.Render(fmt.Sprintf("Begining %s Configuration.", choice)) + } + if m.Quitting { + return quitTextStyle.Render("Aborting Configuration.") + } + return "\n" + m.List.View() +} + +func (m Model) Retrieve() string { + return choice +} diff --git a/vendor/github.com/atotto/clipboard/.travis.yml b/vendor/github.com/atotto/clipboard/.travis.yml new file mode 100644 index 0000000..23f21d8 --- /dev/null +++ b/vendor/github.com/atotto/clipboard/.travis.yml @@ -0,0 +1,22 @@ +language: go + +os: + - linux + - osx + - windows + +go: + - go1.13.x + - go1.x + +services: + - xvfb + +before_install: + - export DISPLAY=:99.0 + +script: + - if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo apt-get install xsel; fi + - go test -v . + - if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo apt-get install xclip; fi + - go test -v . diff --git a/vendor/github.com/atotto/clipboard/LICENSE b/vendor/github.com/atotto/clipboard/LICENSE new file mode 100644 index 0000000..dee3257 --- /dev/null +++ b/vendor/github.com/atotto/clipboard/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2013 Ato Araki. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of @atotto. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/atotto/clipboard/README.md b/vendor/github.com/atotto/clipboard/README.md new file mode 100644 index 0000000..41fdd57 --- /dev/null +++ b/vendor/github.com/atotto/clipboard/README.md @@ -0,0 +1,48 @@ +[![Build Status](https://travis-ci.org/atotto/clipboard.svg?branch=master)](https://travis-ci.org/atotto/clipboard) + +[![GoDoc](https://godoc.org/github.com/atotto/clipboard?status.svg)](http://godoc.org/github.com/atotto/clipboard) + +# Clipboard for Go + +Provide copying and pasting to the Clipboard for Go. + +Build: + + $ go get github.com/atotto/clipboard + +Platforms: + +* OSX +* Windows 7 (probably work on other Windows) +* Linux, Unix (requires 'xclip' or 'xsel' command to be installed) + + +Document: + +* http://godoc.org/github.com/atotto/clipboard + +Notes: + +* Text string only +* UTF-8 text encoding only (no conversion) + +TODO: + +* Clipboard watcher(?) + +## Commands: + +paste shell command: + + $ go get github.com/atotto/clipboard/cmd/gopaste + $ # example: + $ gopaste > document.txt + +copy shell command: + + $ go get github.com/atotto/clipboard/cmd/gocopy + $ # example: + $ cat document.txt | gocopy + + + diff --git a/vendor/github.com/atotto/clipboard/clipboard.go b/vendor/github.com/atotto/clipboard/clipboard.go new file mode 100644 index 0000000..d7907d3 --- /dev/null +++ b/vendor/github.com/atotto/clipboard/clipboard.go @@ -0,0 +1,20 @@ +// Copyright 2013 @atotto. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package clipboard read/write on clipboard +package clipboard + +// ReadAll read string from clipboard +func ReadAll() (string, error) { + return readAll() +} + +// WriteAll write string to clipboard +func WriteAll(text string) error { + return writeAll(text) +} + +// Unsupported might be set true during clipboard init, to help callers decide +// whether or not to offer clipboard options. +var Unsupported bool diff --git a/vendor/github.com/atotto/clipboard/clipboard_darwin.go b/vendor/github.com/atotto/clipboard/clipboard_darwin.go new file mode 100644 index 0000000..6f33078 --- /dev/null +++ b/vendor/github.com/atotto/clipboard/clipboard_darwin.go @@ -0,0 +1,52 @@ +// Copyright 2013 @atotto. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build darwin + +package clipboard + +import ( + "os/exec" +) + +var ( + pasteCmdArgs = "pbpaste" + copyCmdArgs = "pbcopy" +) + +func getPasteCommand() *exec.Cmd { + return exec.Command(pasteCmdArgs) +} + +func getCopyCommand() *exec.Cmd { + return exec.Command(copyCmdArgs) +} + +func readAll() (string, error) { + pasteCmd := getPasteCommand() + out, err := pasteCmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func writeAll(text string) error { + copyCmd := getCopyCommand() + in, err := copyCmd.StdinPipe() + if err != nil { + return err + } + + if err := copyCmd.Start(); err != nil { + return err + } + if _, err := in.Write([]byte(text)); err != nil { + return err + } + if err := in.Close(); err != nil { + return err + } + return copyCmd.Wait() +} diff --git a/vendor/github.com/atotto/clipboard/clipboard_plan9.go b/vendor/github.com/atotto/clipboard/clipboard_plan9.go new file mode 100644 index 0000000..9d2fef4 --- /dev/null +++ b/vendor/github.com/atotto/clipboard/clipboard_plan9.go @@ -0,0 +1,42 @@ +// Copyright 2013 @atotto. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build plan9 + +package clipboard + +import ( + "os" + "io/ioutil" +) + +func readAll() (string, error) { + f, err := os.Open("/dev/snarf") + if err != nil { + return "", err + } + defer f.Close() + + str, err := ioutil.ReadAll(f) + if err != nil { + return "", err + } + + return string(str), nil +} + +func writeAll(text string) error { + f, err := os.OpenFile("/dev/snarf", os.O_WRONLY, 0666) + if err != nil { + return err + } + defer f.Close() + + _, err = f.Write([]byte(text)) + if err != nil { + return err + } + + return nil +} diff --git a/vendor/github.com/atotto/clipboard/clipboard_unix.go b/vendor/github.com/atotto/clipboard/clipboard_unix.go new file mode 100644 index 0000000..d9f6a56 --- /dev/null +++ b/vendor/github.com/atotto/clipboard/clipboard_unix.go @@ -0,0 +1,149 @@ +// Copyright 2013 @atotto. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build freebsd linux netbsd openbsd solaris dragonfly + +package clipboard + +import ( + "errors" + "os" + "os/exec" +) + +const ( + xsel = "xsel" + xclip = "xclip" + powershellExe = "powershell.exe" + clipExe = "clip.exe" + wlcopy = "wl-copy" + wlpaste = "wl-paste" + termuxClipboardGet = "termux-clipboard-get" + termuxClipboardSet = "termux-clipboard-set" +) + +var ( + Primary bool + trimDos bool + + pasteCmdArgs []string + copyCmdArgs []string + + xselPasteArgs = []string{xsel, "--output", "--clipboard"} + xselCopyArgs = []string{xsel, "--input", "--clipboard"} + + xclipPasteArgs = []string{xclip, "-out", "-selection", "clipboard"} + xclipCopyArgs = []string{xclip, "-in", "-selection", "clipboard"} + + powershellExePasteArgs = []string{powershellExe, "Get-Clipboard"} + clipExeCopyArgs = []string{clipExe} + + wlpasteArgs = []string{wlpaste, "--no-newline"} + wlcopyArgs = []string{wlcopy} + + termuxPasteArgs = []string{termuxClipboardGet} + termuxCopyArgs = []string{termuxClipboardSet} + + missingCommands = errors.New("No clipboard utilities available. Please install xsel, xclip, wl-clipboard or Termux:API add-on for termux-clipboard-get/set.") +) + +func init() { + if os.Getenv("WAYLAND_DISPLAY") != "" { + pasteCmdArgs = wlpasteArgs + copyCmdArgs = wlcopyArgs + + if _, err := exec.LookPath(wlcopy); err == nil { + if _, err := exec.LookPath(wlpaste); err == nil { + return + } + } + } + + pasteCmdArgs = xclipPasteArgs + copyCmdArgs = xclipCopyArgs + + if _, err := exec.LookPath(xclip); err == nil { + return + } + + pasteCmdArgs = xselPasteArgs + copyCmdArgs = xselCopyArgs + + if _, err := exec.LookPath(xsel); err == nil { + return + } + + pasteCmdArgs = termuxPasteArgs + copyCmdArgs = termuxCopyArgs + + if _, err := exec.LookPath(termuxClipboardSet); err == nil { + if _, err := exec.LookPath(termuxClipboardGet); err == nil { + return + } + } + + pasteCmdArgs = powershellExePasteArgs + copyCmdArgs = clipExeCopyArgs + trimDos = true + + if _, err := exec.LookPath(clipExe); err == nil { + if _, err := exec.LookPath(powershellExe); err == nil { + return + } + } + + Unsupported = true +} + +func getPasteCommand() *exec.Cmd { + if Primary { + pasteCmdArgs = pasteCmdArgs[:1] + } + return exec.Command(pasteCmdArgs[0], pasteCmdArgs[1:]...) +} + +func getCopyCommand() *exec.Cmd { + if Primary { + copyCmdArgs = copyCmdArgs[:1] + } + return exec.Command(copyCmdArgs[0], copyCmdArgs[1:]...) +} + +func readAll() (string, error) { + if Unsupported { + return "", missingCommands + } + pasteCmd := getPasteCommand() + out, err := pasteCmd.Output() + if err != nil { + return "", err + } + result := string(out) + if trimDos && len(result) > 1 { + result = result[:len(result)-2] + } + return result, nil +} + +func writeAll(text string) error { + if Unsupported { + return missingCommands + } + copyCmd := getCopyCommand() + in, err := copyCmd.StdinPipe() + if err != nil { + return err + } + + if err := copyCmd.Start(); err != nil { + return err + } + if _, err := in.Write([]byte(text)); err != nil { + return err + } + if err := in.Close(); err != nil { + return err + } + return copyCmd.Wait() +} diff --git a/vendor/github.com/atotto/clipboard/clipboard_windows.go b/vendor/github.com/atotto/clipboard/clipboard_windows.go new file mode 100644 index 0000000..253bb93 --- /dev/null +++ b/vendor/github.com/atotto/clipboard/clipboard_windows.go @@ -0,0 +1,157 @@ +// Copyright 2013 @atotto. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build windows + +package clipboard + +import ( + "runtime" + "syscall" + "time" + "unsafe" +) + +const ( + cfUnicodetext = 13 + gmemMoveable = 0x0002 +) + +var ( + user32 = syscall.MustLoadDLL("user32") + isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable") + openClipboard = user32.MustFindProc("OpenClipboard") + closeClipboard = user32.MustFindProc("CloseClipboard") + emptyClipboard = user32.MustFindProc("EmptyClipboard") + getClipboardData = user32.MustFindProc("GetClipboardData") + setClipboardData = user32.MustFindProc("SetClipboardData") + + kernel32 = syscall.NewLazyDLL("kernel32") + globalAlloc = kernel32.NewProc("GlobalAlloc") + globalFree = kernel32.NewProc("GlobalFree") + globalLock = kernel32.NewProc("GlobalLock") + globalUnlock = kernel32.NewProc("GlobalUnlock") + lstrcpy = kernel32.NewProc("lstrcpyW") +) + +// waitOpenClipboard opens the clipboard, waiting for up to a second to do so. +func waitOpenClipboard() error { + started := time.Now() + limit := started.Add(time.Second) + var r uintptr + var err error + for time.Now().Before(limit) { + r, _, err = openClipboard.Call(0) + if r != 0 { + return nil + } + time.Sleep(time.Millisecond) + } + return err +} + +func readAll() (string, error) { + // LockOSThread ensure that the whole method will keep executing on the same thread from begin to end (it actually locks the goroutine thread attribution). + // Otherwise if the goroutine switch thread during execution (which is a common practice), the OpenClipboard and CloseClipboard will happen on two different threads, and it will result in a clipboard deadlock. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if formatAvailable, _, err := isClipboardFormatAvailable.Call(cfUnicodetext); formatAvailable == 0 { + return "", err + } + err := waitOpenClipboard() + if err != nil { + return "", err + } + + h, _, err := getClipboardData.Call(cfUnicodetext) + if h == 0 { + _, _, _ = closeClipboard.Call() + return "", err + } + + l, _, err := globalLock.Call(h) + if l == 0 { + _, _, _ = closeClipboard.Call() + return "", err + } + + text := syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(l))[:]) + + r, _, err := globalUnlock.Call(h) + if r == 0 { + _, _, _ = closeClipboard.Call() + return "", err + } + + closed, _, err := closeClipboard.Call() + if closed == 0 { + return "", err + } + return text, nil +} + +func writeAll(text string) error { + // LockOSThread ensure that the whole method will keep executing on the same thread from begin to end (it actually locks the goroutine thread attribution). + // Otherwise if the goroutine switch thread during execution (which is a common practice), the OpenClipboard and CloseClipboard will happen on two different threads, and it will result in a clipboard deadlock. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + err := waitOpenClipboard() + if err != nil { + return err + } + + r, _, err := emptyClipboard.Call(0) + if r == 0 { + _, _, _ = closeClipboard.Call() + return err + } + + data := syscall.StringToUTF16(text) + + // "If the hMem parameter identifies a memory object, the object must have + // been allocated using the function with the GMEM_MOVEABLE flag." + h, _, err := globalAlloc.Call(gmemMoveable, uintptr(len(data)*int(unsafe.Sizeof(data[0])))) + if h == 0 { + _, _, _ = closeClipboard.Call() + return err + } + defer func() { + if h != 0 { + globalFree.Call(h) + } + }() + + l, _, err := globalLock.Call(h) + if l == 0 { + _, _, _ = closeClipboard.Call() + return err + } + + r, _, err = lstrcpy.Call(l, uintptr(unsafe.Pointer(&data[0]))) + if r == 0 { + _, _, _ = closeClipboard.Call() + return err + } + + r, _, err = globalUnlock.Call(h) + if r == 0 { + if err.(syscall.Errno) != 0 { + _, _, _ = closeClipboard.Call() + return err + } + } + + r, _, err = setClipboardData.Call(cfUnicodetext, h) + if r == 0 { + _, _, _ = closeClipboard.Call() + return err + } + h = 0 // suppress deferred cleanup + closed, _, err := closeClipboard.Call() + if closed == 0 { + return err + } + return nil +} diff --git a/vendor/github.com/charmbracelet/bubbles/help/help.go b/vendor/github.com/charmbracelet/bubbles/help/help.go new file mode 100644 index 0000000..90971ac --- /dev/null +++ b/vendor/github.com/charmbracelet/bubbles/help/help.go @@ -0,0 +1,233 @@ +package help + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// KeyMap is a map of keybindings used to generate help. Since it's an +// interface it can be any type, though struct or a map[string][]key.Binding +// are likely candidates. +// +// Note that if a key is disabled (via key.Binding.SetEnabled) it will not be +// rendered in the help view, so in theory generated help should self-manage. +type KeyMap interface { + + // ShortHelp returns a slice of bindings to be displayed in the short + // version of the help. The help bubble will render help in the order in + // which the help items are returned here. + ShortHelp() []key.Binding + + // MoreHelp returns an extended group of help items, grouped by columns. + // The help bubble will render the help in the order in which the help + // items are returned here. + FullHelp() [][]key.Binding +} + +// Styles is a set of available style definitions for the Help bubble. +type Styles struct { + Ellipsis lipgloss.Style + + // Styling for the short help + ShortKey lipgloss.Style + ShortDesc lipgloss.Style + ShortSeparator lipgloss.Style + + // Styling for the full help + FullKey lipgloss.Style + FullDesc lipgloss.Style + FullSeparator lipgloss.Style +} + +// Model contains the state of the help view. +type Model struct { + Width int + ShowAll bool // if true, render the "full" help menu + + ShortSeparator string + FullSeparator string + + // The symbol we use in the short help when help items have been truncated + // due to width. Periods of ellipsis by default. + Ellipsis string + + Styles Styles +} + +// New creates a new help view with some useful defaults. +func New() Model { + keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ + Light: "#909090", + Dark: "#626262", + }) + + descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ + Light: "#B2B2B2", + Dark: "#4A4A4A", + }) + + sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ + Light: "#DDDADA", + Dark: "#3C3C3C", + }) + + return Model{ + ShortSeparator: " • ", + FullSeparator: " ", + Ellipsis: "…", + Styles: Styles{ + ShortKey: keyStyle, + ShortDesc: descStyle, + ShortSeparator: sepStyle, + Ellipsis: sepStyle.Copy(), + FullKey: keyStyle.Copy(), + FullDesc: descStyle.Copy(), + FullSeparator: sepStyle.Copy(), + }, + } +} + +// NewModel creates a new help view with some useful defaults. +// +// Deprecated. Use New instead. +var NewModel = New + +// Update helps satisfy the Bubble Tea Model interface. It's a no-op. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + return m, nil +} + +// View renders the help view's current state. +func (m Model) View(k KeyMap) string { + if m.ShowAll { + return m.FullHelpView(k.FullHelp()) + } + return m.ShortHelpView(k.ShortHelp()) +} + +// ShortHelpView renders a single line help view from a slice of keybindings. +// If the line is longer than the maximum width it will be gracefully +// truncated, showing only as many help items as possible. +func (m Model) ShortHelpView(bindings []key.Binding) string { + if len(bindings) == 0 { + return "" + } + + var b strings.Builder + var totalWidth int + var separator = m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator) + + for i, kb := range bindings { + if !kb.Enabled() { + continue + } + + var sep string + if totalWidth > 0 && i < len(bindings) { + sep = separator + } + + str := sep + + m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " + + m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc) + + w := lipgloss.Width(str) + + // If adding this help item would go over the available width, stop + // drawing. + if m.Width > 0 && totalWidth+w > m.Width { + // Although if there's room for an ellipsis, print that. + tail := " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis) + tailWidth := lipgloss.Width(tail) + + if totalWidth+tailWidth < m.Width { + b.WriteString(tail) + } + + break + } + + totalWidth += w + b.WriteString(str) + } + + return b.String() +} + +// FullHelpView renders help columns from a slice of key binding slices. Each +// top level slice entry renders into a column. +func (m Model) FullHelpView(groups [][]key.Binding) string { + if len(groups) == 0 { + return "" + } + + // Linter note: at this time we don't think it's worth the additional + // code complexity involved in preallocating this slice. + //nolint:prealloc + var ( + out []string + + totalWidth int + sep = m.Styles.FullSeparator.Render(m.FullSeparator) + sepWidth = lipgloss.Width(sep) + ) + + // Iterate over groups to build columns + for i, group := range groups { + if group == nil || !shouldRenderColumn(group) { + continue + } + + var ( + keys []string + descriptions []string + ) + + // Separate keys and descriptions into different slices + for _, kb := range group { + if !kb.Enabled() { + continue + } + keys = append(keys, kb.Help().Key) + descriptions = append(descriptions, kb.Help().Desc) + } + + col := lipgloss.JoinHorizontal(lipgloss.Top, + m.Styles.FullKey.Render(strings.Join(keys, "\n")), + m.Styles.FullKey.Render(" "), + m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")), + ) + + // Column + totalWidth += lipgloss.Width(col) + if totalWidth > m.Width { + break + } + + out = append(out, col) + + // Separator + if i < len(group)-1 { + totalWidth += sepWidth + if totalWidth > m.Width { + break + } + } + + out = append(out, sep) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, out...) +} + +func shouldRenderColumn(b []key.Binding) (ok bool) { + for _, v := range b { + if v.Enabled() { + return true + } + } + return false +} diff --git a/vendor/github.com/charmbracelet/bubbles/list/defaultitem.go b/vendor/github.com/charmbracelet/bubbles/list/defaultitem.go new file mode 100644 index 0000000..74e4ee2 --- /dev/null +++ b/vendor/github.com/charmbracelet/bubbles/list/defaultitem.go @@ -0,0 +1,227 @@ +package list + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/truncate" +) + +// DefaultItemStyles defines styling for a default list item. +// See DefaultItemView for when these come into play. +type DefaultItemStyles struct { + // The Normal state. + NormalTitle lipgloss.Style + NormalDesc lipgloss.Style + + // The selected item state. + SelectedTitle lipgloss.Style + SelectedDesc lipgloss.Style + + // The dimmed state, for when the filter input is initially activated. + DimmedTitle lipgloss.Style + DimmedDesc lipgloss.Style + + // Charcters matching the current filter, if any. + FilterMatch lipgloss.Style +} + +// NewDefaultItemStyles returns style definitions for a default item. See +// DefaultItemView for when these come into play. +func NewDefaultItemStyles() (s DefaultItemStyles) { + s.NormalTitle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}). + Padding(0, 0, 0, 2) + + s.NormalDesc = s.NormalTitle.Copy(). + Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}) + + s.SelectedTitle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}). + Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}). + Padding(0, 0, 0, 1) + + s.SelectedDesc = s.SelectedTitle.Copy(). + Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}) + + s.DimmedTitle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). + Padding(0, 0, 0, 2) + + s.DimmedDesc = s.DimmedTitle.Copy(). + Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}) + + s.FilterMatch = lipgloss.NewStyle().Underline(true) + + return s +} + +// DefaultItem describes an items designed to work with DefaultDelegate. +type DefaultItem interface { + Item + Title() string + Description() string +} + +// DefaultDelegate is a standard delegate designed to work in lists. It's +// styled by DefaultItemStyles, which can be customized as you like. +// +// The description line can be hidden by setting Description to false, which +// renders the list as single-line-items. The spacing between items can be set +// with the SetSpacing method. +// +// Setting UpdateFunc is optional. If it's set it will be called when the +// ItemDelegate called, which is called when the list's Update function is +// invoked. +// +// Settings ShortHelpFunc and FullHelpFunc is optional. They can can be set to +// include items in the list's default short and full help menus. +type DefaultDelegate struct { + ShowDescription bool + Styles DefaultItemStyles + UpdateFunc func(tea.Msg, *Model) tea.Cmd + ShortHelpFunc func() []key.Binding + FullHelpFunc func() [][]key.Binding + height int + spacing int +} + +// NewDefaultDelegate creates a new delegate with default styles. +func NewDefaultDelegate() DefaultDelegate { + return DefaultDelegate{ + ShowDescription: true, + Styles: NewDefaultItemStyles(), + height: 2, + spacing: 1, + } +} + +// SetHeight sets delegate's preferred height. +func (d *DefaultDelegate) SetHeight(i int) { + d.height = i +} + +// Height returns the delegate's preferred height. +// This has effect only if ShowDescription is true, +// otherwise height is always 1. +func (d DefaultDelegate) Height() int { + if d.ShowDescription { + return d.height + } + return 1 +} + +// SetSpacing set the delegate's spacing. +func (d *DefaultDelegate) SetSpacing(i int) { + d.spacing = i +} + +// Spacing returns the delegate's spacing. +func (d DefaultDelegate) Spacing() int { + return d.spacing +} + +// Update checks whether the delegate's UpdateFunc is set and calls it. +func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd { + if d.UpdateFunc == nil { + return nil + } + return d.UpdateFunc(msg, m) +} + +// Render prints an item. +func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) { + var ( + title, desc string + matchedRunes []int + s = &d.Styles + ) + + if i, ok := item.(DefaultItem); ok { + title = i.Title() + desc = i.Description() + } else { + return + } + + if m.width <= 0 { + // short-circuit + return + } + + // Prevent text from exceeding list width + textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight()) + title = truncate.StringWithTail(title, textwidth, ellipsis) + if d.ShowDescription { + var lines []string + for i, line := range strings.Split(desc, "\n") { + if i >= d.height-1 { + break + } + lines = append(lines, truncate.StringWithTail(line, textwidth, ellipsis)) + } + desc = strings.Join(lines, "\n") + } + + // Conditions + var ( + isSelected = index == m.Index() + emptyFilter = m.FilterState() == Filtering && m.FilterValue() == "" + isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied + ) + + if isFiltered && index < len(m.filteredItems) { + // Get indices of matched characters + matchedRunes = m.MatchesForItem(index) + } + + if emptyFilter { + title = s.DimmedTitle.Render(title) + desc = s.DimmedDesc.Render(desc) + } else if isSelected && m.FilterState() != Filtering { + if isFiltered { + // Highlight matches + unmatched := s.SelectedTitle.Inline(true) + matched := unmatched.Copy().Inherit(s.FilterMatch) + title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) + } + title = s.SelectedTitle.Render(title) + desc = s.SelectedDesc.Render(desc) + } else { + if isFiltered { + // Highlight matches + unmatched := s.NormalTitle.Inline(true) + matched := unmatched.Copy().Inherit(s.FilterMatch) + title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) + } + title = s.NormalTitle.Render(title) + desc = s.NormalDesc.Render(desc) + } + + if d.ShowDescription { + fmt.Fprintf(w, "%s\n%s", title, desc) + return + } + fmt.Fprintf(w, "%s", title) +} + +// ShortHelp returns the delegate's short help. +func (d DefaultDelegate) ShortHelp() []key.Binding { + if d.ShortHelpFunc != nil { + return d.ShortHelpFunc() + } + return nil +} + +// FullHelp returns the delegate's full help. +func (d DefaultDelegate) FullHelp() [][]key.Binding { + if d.FullHelpFunc != nil { + return d.FullHelpFunc() + } + return nil +} diff --git a/vendor/github.com/charmbracelet/bubbles/list/keys.go b/vendor/github.com/charmbracelet/bubbles/list/keys.go new file mode 100644 index 0000000..421a247 --- /dev/null +++ b/vendor/github.com/charmbracelet/bubbles/list/keys.go @@ -0,0 +1,97 @@ +package list + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which +// is used to render the menu menu. +type KeyMap struct { + // Keybindings used when browsing the list. + CursorUp key.Binding + CursorDown key.Binding + NextPage key.Binding + PrevPage key.Binding + GoToStart key.Binding + GoToEnd key.Binding + Filter key.Binding + ClearFilter key.Binding + + // Keybindings used when setting a filter. + CancelWhileFiltering key.Binding + AcceptWhileFiltering key.Binding + + // Help toggle keybindings. + ShowFullHelp key.Binding + CloseFullHelp key.Binding + + // The quit keybinding. This won't be caught when filtering. + Quit key.Binding + + // The quit-no-matter-what keybinding. This will be caught when filtering. + ForceQuit key.Binding +} + +// DefaultKeyMap returns a default set of keybindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + // Browsing. + CursorUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + CursorDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + PrevPage: key.NewBinding( + key.WithKeys("left", "h", "pgup", "b", "u"), + key.WithHelp("←/h/pgup", "prev page"), + ), + NextPage: key.NewBinding( + key.WithKeys("right", "l", "pgdown", "f", "d"), + key.WithHelp("→/l/pgdn", "next page"), + ), + GoToStart: key.NewBinding( + key.WithKeys("home", "g"), + key.WithHelp("g/home", "go to start"), + ), + GoToEnd: key.NewBinding( + key.WithKeys("end", "G"), + key.WithHelp("G/end", "go to end"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + ClearFilter: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "clear filter"), + ), + + // Filtering. + CancelWhileFiltering: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + AcceptWhileFiltering: key.NewBinding( + key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"), + key.WithHelp("enter", "apply filter"), + ), + + // Toggle help. + ShowFullHelp: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "more"), + ), + CloseFullHelp: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "close help"), + ), + + // Quitting. + Quit: key.NewBinding( + key.WithKeys("q", "esc"), + key.WithHelp("q", "quit"), + ), + ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), + } +} diff --git a/vendor/github.com/charmbracelet/bubbles/list/list.go b/vendor/github.com/charmbracelet/bubbles/list/list.go new file mode 100644 index 0000000..bec5e6f --- /dev/null +++ b/vendor/github.com/charmbracelet/bubbles/list/list.go @@ -0,0 +1,1264 @@ +// Package list provides a feature-rich Bubble Tea component for browsing +// a general purpose list of items. It features optional filtering, pagination, +// help, status messages, and a spinner to indicate activity. +package list + +import ( + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/paginator" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/ansi" + "github.com/muesli/reflow/truncate" + "github.com/sahilm/fuzzy" +) + +// Item is an item that appears in the list. +type Item interface { + // Filter value is the value we use when filtering against this item when + // we're filtering the list. + FilterValue() string +} + +// ItemDelegate encapsulates the general functionality for all list items. The +// benefit to separating this logic from the item itself is that you can change +// the functionality of items without changing the actual items themselves. +// +// Note that if the delegate also implements help.KeyMap delegate-related +// help items will be added to the help view. +type ItemDelegate interface { + // Render renders the item's view. + Render(w io.Writer, m Model, index int, item Item) + + // Height is the height of the list item. + Height() int + + // Spacing is the size of the horizontal gap between list items in cells. + Spacing() int + + // Update is the update loop for items. All messages in the list's update + // loop will pass through here except when the user is setting a filter. + // Use this method to perform item-level updates appropriate to this + // delegate. + Update(msg tea.Msg, m *Model) tea.Cmd +} + +type filteredItem struct { + item Item // item matched + matches []int // rune indices of matched items +} + +type filteredItems []filteredItem + +func (f filteredItems) items() []Item { + agg := make([]Item, len(f)) + for i, v := range f { + agg[i] = v.item + } + return agg +} + +// FilterMatchesMsg contains data about items matched during filtering. The +// message should be routed to Update for processing. +type FilterMatchesMsg []filteredItem + +// FilterFunc takes a term and a list of strings to search through +// (defined by Item#FilterValue). +// It should return a sorted list of ranks. +type FilterFunc func(string, []string) []Rank + +// Rank defines a rank for a given item. +type Rank struct { + // The index of the item in the original input. + Index int + // Indices of the actual word that were matched against the filter term. + MatchedIndexes []int +} + +// DefaultFilter uses the sahilm/fuzzy to filter through the list. +// This is set by default. +func DefaultFilter(term string, targets []string) []Rank { + var ranks = fuzzy.Find(term, targets) + sort.Stable(ranks) + result := make([]Rank, len(ranks)) + for i, r := range ranks { + result[i] = Rank{ + Index: r.Index, + MatchedIndexes: r.MatchedIndexes, + } + } + return result +} + +type statusMessageTimeoutMsg struct{} + +// FilterState describes the current filtering state on the model. +type FilterState int + +// Possible filter states. +const ( + Unfiltered FilterState = iota // no filter set + Filtering // user is actively setting a filter + FilterApplied // a filter is applied and user is not editing filter +) + +// String returns a human-readable string of the current filter state. +func (f FilterState) String() string { + return [...]string{ + "unfiltered", + "filtering", + "filter applied", + }[f] +} + +// Model contains the state of this component. +type Model struct { + showTitle bool + showFilter bool + showStatusBar bool + showPagination bool + showHelp bool + filteringEnabled bool + + itemNameSingular string + itemNamePlural string + + Title string + Styles Styles + + // Key mappings for navigating the list. + KeyMap KeyMap + + // Filter is used to filter the list. + Filter FilterFunc + + disableQuitKeybindings bool + + // Additional key mappings for the short and full help views. This allows + // you to add additional key mappings to the help menu without + // re-implementing the help component. Of course, you can also disable the + // list's help component and implement a new one if you need more + // flexibility. + AdditionalShortHelpKeys func() []key.Binding + AdditionalFullHelpKeys func() []key.Binding + + spinner spinner.Model + showSpinner bool + width int + height int + Paginator paginator.Model + cursor int + Help help.Model + FilterInput textinput.Model + filterState FilterState + + // How long status messages should stay visible. By default this is + // 1 second. + StatusMessageLifetime time.Duration + + statusMessage string + statusMessageTimer *time.Timer + + // The master set of items we're working with. + items []Item + + // Filtered items we're currently displaying. Filtering, toggles and so on + // will alter this slice so we can show what is relevant. For that reason, + // this field should be considered ephemeral. + filteredItems filteredItems + + delegate ItemDelegate +} + +// New returns a new model with sensible defaults. +func New(items []Item, delegate ItemDelegate, width, height int) Model { + styles := DefaultStyles() + + sp := spinner.NewModel() + sp.Spinner = spinner.Line + sp.Style = styles.Spinner + + filterInput := textinput.NewModel() + filterInput.Prompt = "Filter: " + filterInput.PromptStyle = styles.FilterPrompt + filterInput.CursorStyle = styles.FilterCursor + filterInput.CharLimit = 64 + filterInput.Focus() + + p := paginator.NewModel() + p.Type = paginator.Dots + p.ActiveDot = styles.ActivePaginationDot.String() + p.InactiveDot = styles.InactivePaginationDot.String() + + m := Model{ + showTitle: true, + showFilter: true, + showStatusBar: true, + showPagination: true, + showHelp: true, + itemNameSingular: "item", + itemNamePlural: "items", + filteringEnabled: true, + KeyMap: DefaultKeyMap(), + Filter: DefaultFilter, + Styles: styles, + Title: "List", + FilterInput: filterInput, + StatusMessageLifetime: time.Second, + + width: width, + height: height, + delegate: delegate, + items: items, + Paginator: p, + spinner: sp, + Help: help.NewModel(), + } + + m.updatePagination() + m.updateKeybindings() + return m +} + +// NewModel returns a new model with sensible defaults. +// +// Deprecated. Use New instead. +var NewModel = New + +// SetFilteringEnabled enables or disables filtering. Note that this is different +// from ShowFilter, which merely hides or shows the input view. +func (m *Model) SetFilteringEnabled(v bool) { + m.filteringEnabled = v + if !v { + m.resetFiltering() + } + m.updateKeybindings() +} + +// FilteringEnabled returns whether or not filtering is enabled. +func (m Model) FilteringEnabled() bool { + return m.filteringEnabled +} + +// SetShowTitle shows or hides the title bar. +func (m *Model) SetShowTitle(v bool) { + m.showTitle = v + m.updatePagination() +} + +// ShowTitle returns whether or not the title bar is set to be rendered. +func (m Model) ShowTitle() bool { + return m.showTitle +} + +// SetShowFilter shows or hides the filer bar. Note that this does not disable +// filtering, it simply hides the built-in filter view. This allows you to +// use the FilterInput to render the filtering UI differently without having to +// re-implement filtering from scratch. +// +// To disable filtering entirely use EnableFiltering. +func (m *Model) SetShowFilter(v bool) { + m.showFilter = v + m.updatePagination() +} + +// ShowFilter returns whether or not the filter is set to be rendered. Note +// that this is separate from FilteringEnabled, so filtering can be hidden yet +// still invoked. This allows you to render filtering differently without +// having to re-implement it from scratch. +func (m Model) ShowFilter() bool { + return m.showFilter +} + +// SetShowStatusBar shows or hides the view that displays metadata about the +// list, such as item counts. +func (m *Model) SetShowStatusBar(v bool) { + m.showStatusBar = v + m.updatePagination() +} + +// ShowStatusBar returns whether or not the status bar is set to be rendered. +func (m Model) ShowStatusBar() bool { + return m.showStatusBar +} + +// SetStatusBarItemName defines a replacement for the items identifier. +// Defaults to item/items. +func (m *Model) SetStatusBarItemName(singular, plural string) { + m.itemNameSingular = singular + m.itemNamePlural = plural +} + +// StatusBarItemName returns singular and plural status bar item names. +func (m Model) StatusBarItemName() (string, string) { + return m.itemNameSingular, m.itemNamePlural +} + +// SetShowPagination hides or shoes the paginator. Note that pagination will +// still be active, it simply won't be displayed. +func (m *Model) SetShowPagination(v bool) { + m.showPagination = v + m.updatePagination() +} + +// ShowPagination returns whether the pagination is visible. +func (m *Model) ShowPagination() bool { + return m.showPagination +} + +// SetShowHelp shows or hides the help view. +func (m *Model) SetShowHelp(v bool) { + m.showHelp = v + m.updatePagination() +} + +// ShowHelp returns whether or not the help is set to be rendered. +func (m Model) ShowHelp() bool { + return m.showHelp +} + +// Items returns the items in the list. +func (m Model) Items() []Item { + return m.items +} + +// Set the items available in the list. This returns a command. +func (m *Model) SetItems(i []Item) tea.Cmd { + var cmd tea.Cmd + m.items = i + + if m.filterState != Unfiltered { + m.filteredItems = nil + cmd = filterItems(*m) + } + + m.updatePagination() + m.updateKeybindings() + return cmd +} + +// Select selects the given index of the list and goes to its respective page. +func (m *Model) Select(index int) { + m.Paginator.Page = index / m.Paginator.PerPage + m.cursor = index % m.Paginator.PerPage +} + +// ResetSelected resets the selected item to the first item in the first page of the list. +func (m *Model) ResetSelected() { + m.Select(0) +} + +// ResetFilter resets the current filtering state. +func (m *Model) ResetFilter() { + m.resetFiltering() +} + +// Replace an item at the given index. This returns a command. +func (m *Model) SetItem(index int, item Item) tea.Cmd { + var cmd tea.Cmd + m.items[index] = item + + if m.filterState != Unfiltered { + cmd = filterItems(*m) + } + + m.updatePagination() + return cmd +} + +// Insert an item at the given index. If index is out of the upper bound, the +// item will be appended. This returns a command. +func (m *Model) InsertItem(index int, item Item) tea.Cmd { + var cmd tea.Cmd + m.items = insertItemIntoSlice(m.items, item, index) + + if m.filterState != Unfiltered { + cmd = filterItems(*m) + } + + m.updatePagination() + m.updateKeybindings() + return cmd +} + +// RemoveItem removes an item at the given index. If the index is out of bounds +// this will be a no-op. O(n) complexity, which probably won't matter in the +// case of a TUI. +func (m *Model) RemoveItem(index int) { + m.items = removeItemFromSlice(m.items, index) + if m.filterState != Unfiltered { + m.filteredItems = removeFilterMatchFromSlice(m.filteredItems, index) + if len(m.filteredItems) == 0 { + m.resetFiltering() + } + } + m.updatePagination() +} + +// Set the item delegate. +func (m *Model) SetDelegate(d ItemDelegate) { + m.delegate = d + m.updatePagination() +} + +// VisibleItems returns the total items available to be shown. +func (m Model) VisibleItems() []Item { + if m.filterState != Unfiltered { + return m.filteredItems.items() + } + return m.items +} + +// SelectedItems returns the current selected item in the list. +func (m Model) SelectedItem() Item { + i := m.Index() + + items := m.VisibleItems() + if i < 0 || len(items) == 0 || len(items) <= i { + return nil + } + + return items[i] +} + +// MatchesForItem returns rune positions matched by the current filter, if any. +// Use this to style runes matched by the active filter. +// +// See DefaultItemView for a usage example. +func (m Model) MatchesForItem(index int) []int { + if m.filteredItems == nil || index >= len(m.filteredItems) { + return nil + } + return m.filteredItems[index].matches +} + +// Index returns the index of the currently selected item as it appears in the +// entire slice of items. +func (m Model) Index() int { + return m.Paginator.Page*m.Paginator.PerPage + m.cursor +} + +// Cursor returns the index of the cursor on the current page. +func (m Model) Cursor() int { + return m.cursor +} + +// CursorUp moves the cursor up. This can also move the state to the previous +// page. +func (m *Model) CursorUp() { + m.cursor-- + + // If we're at the start, stop + if m.cursor < 0 && m.Paginator.Page == 0 { + m.cursor = 0 + return + } + + // Move the cursor as normal + if m.cursor >= 0 { + return + } + + // Go to the previous page + m.Paginator.PrevPage() + m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1 +} + +// CursorDown moves the cursor down. This can also advance the state to the +// next page. +func (m *Model) CursorDown() { + itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) + + m.cursor++ + + // If we're at the end, stop + if m.cursor < itemsOnPage { + return + } + + // Go to the next page + if !m.Paginator.OnLastPage() { + m.Paginator.NextPage() + m.cursor = 0 + return + } + + // During filtering the cursor position can exceed the number of + // itemsOnPage. It's more intuitive to start the cursor at the + // topmost position when moving it down in this scenario. + if m.cursor > itemsOnPage { + m.cursor = 0 + return + } + + m.cursor = itemsOnPage - 1 +} + +// PrevPage moves to the previous page, if available. +func (m Model) PrevPage() { + m.Paginator.PrevPage() +} + +// NextPage moves to the next page, if available. +func (m Model) NextPage() { + m.Paginator.NextPage() +} + +// FilterState returns the current filter state. +func (m Model) FilterState() FilterState { + return m.filterState +} + +// FilterValue returns the current value of the filter. +func (m Model) FilterValue() string { + return m.FilterInput.Value() +} + +// SettingFilter returns whether or not the user is currently editing the +// filter value. It's purely a convenience method for the following: +// +// m.FilterState() == Filtering +// +// It's included here because it's a common thing to check for when +// implementing this component. +func (m Model) SettingFilter() bool { + return m.filterState == Filtering +} + +// IsFiltered returns whether or not the list is currently filtered. +// It's purely a convenience method for the following: +// +// m.FilterState() == FilterApplied +// +func (m Model) IsFiltered() bool { + return m.filterState == FilterApplied +} + +// Width returns the current width setting. +func (m Model) Width() int { + return m.width +} + +// Height returns the current height setting. +func (m Model) Height() int { + return m.height +} + +// SetSpinner allows to set the spinner style. +func (m *Model) SetSpinner(spinner spinner.Spinner) { + m.spinner.Spinner = spinner +} + +// Toggle the spinner. Note that this also returns a command. +func (m *Model) ToggleSpinner() tea.Cmd { + if !m.showSpinner { + return m.StartSpinner() + } + m.StopSpinner() + return nil +} + +// StartSpinner starts the spinner. Note that this returns a command. +func (m *Model) StartSpinner() tea.Cmd { + m.showSpinner = true + return spinner.Tick +} + +// StopSpinner stops the spinner. +func (m *Model) StopSpinner() { + m.showSpinner = false +} + +// Helper for disabling the keybindings used for quitting, in case you want to +// handle this elsewhere in your application. +func (m *Model) DisableQuitKeybindings() { + m.disableQuitKeybindings = true + m.KeyMap.Quit.SetEnabled(false) + m.KeyMap.ForceQuit.SetEnabled(false) +} + +// NewStatusMessage sets a new status message, which will show for a limited +// amount of time. Note that this also returns a command. +func (m *Model) NewStatusMessage(s string) tea.Cmd { + m.statusMessage = s + if m.statusMessageTimer != nil { + m.statusMessageTimer.Stop() + } + + m.statusMessageTimer = time.NewTimer(m.StatusMessageLifetime) + + // Wait for timeout + return func() tea.Msg { + <-m.statusMessageTimer.C + return statusMessageTimeoutMsg{} + } +} + +// SetSize sets the width and height of this component. +func (m *Model) SetSize(width, height int) { + m.setSize(width, height) +} + +// SetWidth sets the width of this component. +func (m *Model) SetWidth(v int) { + m.setSize(v, m.height) +} + +// SetHeight sets the height of this component. +func (m *Model) SetHeight(v int) { + m.setSize(m.width, v) +} + +func (m *Model) setSize(width, height int) { + promptWidth := lipgloss.Width(m.Styles.Title.Render(m.FilterInput.Prompt)) + + m.width = width + m.height = height + m.Help.Width = width + m.FilterInput.Width = width - promptWidth - lipgloss.Width(m.spinnerView()) + m.updatePagination() +} + +func (m *Model) resetFiltering() { + if m.filterState == Unfiltered { + return + } + + m.filterState = Unfiltered + m.FilterInput.Reset() + m.filteredItems = nil + m.updatePagination() + m.updateKeybindings() +} + +func (m Model) itemsAsFilterItems() filteredItems { + fi := make([]filteredItem, len(m.items)) + for i, item := range m.items { + fi[i] = filteredItem{ + item: item, + } + } + return filteredItems(fi) +} + +// Set keybindings according to the filter state. +func (m *Model) updateKeybindings() { + switch m.filterState { + case Filtering: + m.KeyMap.CursorUp.SetEnabled(false) + m.KeyMap.CursorDown.SetEnabled(false) + m.KeyMap.NextPage.SetEnabled(false) + m.KeyMap.PrevPage.SetEnabled(false) + m.KeyMap.GoToStart.SetEnabled(false) + m.KeyMap.GoToEnd.SetEnabled(false) + m.KeyMap.Filter.SetEnabled(false) + m.KeyMap.ClearFilter.SetEnabled(false) + m.KeyMap.CancelWhileFiltering.SetEnabled(true) + m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") + m.KeyMap.Quit.SetEnabled(false) + m.KeyMap.ShowFullHelp.SetEnabled(false) + m.KeyMap.CloseFullHelp.SetEnabled(false) + + default: + hasItems := len(m.items) != 0 + m.KeyMap.CursorUp.SetEnabled(hasItems) + m.KeyMap.CursorDown.SetEnabled(hasItems) + + hasPages := m.Paginator.TotalPages > 1 + m.KeyMap.NextPage.SetEnabled(hasPages) + m.KeyMap.PrevPage.SetEnabled(hasPages) + + m.KeyMap.GoToStart.SetEnabled(hasItems) + m.KeyMap.GoToEnd.SetEnabled(hasItems) + + m.KeyMap.Filter.SetEnabled(m.filteringEnabled && hasItems) + m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied) + m.KeyMap.CancelWhileFiltering.SetEnabled(false) + m.KeyMap.AcceptWhileFiltering.SetEnabled(false) + m.KeyMap.Quit.SetEnabled(!m.disableQuitKeybindings) + + if m.Help.ShowAll { + m.KeyMap.ShowFullHelp.SetEnabled(true) + m.KeyMap.CloseFullHelp.SetEnabled(true) + } else { + minHelp := countEnabledBindings(m.FullHelp()) > 1 + m.KeyMap.ShowFullHelp.SetEnabled(minHelp) + m.KeyMap.CloseFullHelp.SetEnabled(minHelp) + } + } +} + +// Update pagination according to the amount of items for the current state. +func (m *Model) updatePagination() { + index := m.Index() + availHeight := m.height + + if m.showTitle || (m.showFilter && m.filteringEnabled) { + availHeight -= lipgloss.Height(m.titleView()) + } + if m.showStatusBar { + availHeight -= lipgloss.Height(m.statusView()) + } + if m.showPagination { + availHeight -= lipgloss.Height(m.paginationView()) + } + if m.showHelp { + availHeight -= lipgloss.Height(m.helpView()) + } + + m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing())) + + if pages := len(m.VisibleItems()); pages < 1 { + m.Paginator.SetTotalPages(1) + } else { + m.Paginator.SetTotalPages(pages) + } + + // Restore index + m.Paginator.Page = index / m.Paginator.PerPage + m.cursor = index % m.Paginator.PerPage + + // Make sure the page stays in bounds + if m.Paginator.Page >= m.Paginator.TotalPages-1 { + m.Paginator.Page = max(0, m.Paginator.TotalPages-1) + } +} + +func (m *Model) hideStatusMessage() { + m.statusMessage = "" + if m.statusMessageTimer != nil { + m.statusMessageTimer.Stop() + } +} + +// Update is the Bubble Tea update loop. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + if key.Matches(msg, m.KeyMap.ForceQuit) { + return m, tea.Quit + } + + case FilterMatchesMsg: + m.filteredItems = filteredItems(msg) + return m, nil + + case spinner.TickMsg: + newSpinnerModel, cmd := m.spinner.Update(msg) + m.spinner = newSpinnerModel + if m.showSpinner { + cmds = append(cmds, cmd) + } + + case statusMessageTimeoutMsg: + m.hideStatusMessage() + } + + if m.filterState == Filtering { + cmds = append(cmds, m.handleFiltering(msg)) + } else { + cmds = append(cmds, m.handleBrowsing(msg)) + } + + return m, tea.Batch(cmds...) +} + +// Updates for when a user is browsing the list. +func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + numItems := len(m.VisibleItems()) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + // Note: we match clear filter before quit because, by default, they're + // both mapped to escape. + case key.Matches(msg, m.KeyMap.ClearFilter): + m.resetFiltering() + + case key.Matches(msg, m.KeyMap.Quit): + return tea.Quit + + case key.Matches(msg, m.KeyMap.CursorUp): + m.CursorUp() + + case key.Matches(msg, m.KeyMap.CursorDown): + m.CursorDown() + + case key.Matches(msg, m.KeyMap.PrevPage): + m.Paginator.PrevPage() + + case key.Matches(msg, m.KeyMap.NextPage): + m.Paginator.NextPage() + + case key.Matches(msg, m.KeyMap.GoToStart): + m.Paginator.Page = 0 + m.cursor = 0 + + case key.Matches(msg, m.KeyMap.GoToEnd): + m.Paginator.Page = m.Paginator.TotalPages - 1 + m.cursor = m.Paginator.ItemsOnPage(numItems) - 1 + + case key.Matches(msg, m.KeyMap.Filter): + m.hideStatusMessage() + if m.FilterInput.Value() == "" { + // Populate filter with all items only if the filter is empty. + m.filteredItems = m.itemsAsFilterItems() + } + m.Paginator.Page = 0 + m.cursor = 0 + m.filterState = Filtering + m.FilterInput.CursorEnd() + m.FilterInput.Focus() + m.updateKeybindings() + return textinput.Blink + + case key.Matches(msg, m.KeyMap.ShowFullHelp): + fallthrough + case key.Matches(msg, m.KeyMap.CloseFullHelp): + m.Help.ShowAll = !m.Help.ShowAll + m.updatePagination() + } + } + + cmd := m.delegate.Update(msg, m) + cmds = append(cmds, cmd) + + // Keep the index in bounds when paginating + itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) + if m.cursor > itemsOnPage-1 { + m.cursor = max(0, itemsOnPage-1) + } + + return tea.Batch(cmds...) +} + +// Updates for when a user is in the filter editing interface. +func (m *Model) handleFiltering(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + + // Handle keys + if msg, ok := msg.(tea.KeyMsg); ok { + switch { + case key.Matches(msg, m.KeyMap.CancelWhileFiltering): + m.resetFiltering() + m.KeyMap.Filter.SetEnabled(true) + m.KeyMap.ClearFilter.SetEnabled(false) + + case key.Matches(msg, m.KeyMap.AcceptWhileFiltering): + m.hideStatusMessage() + + if len(m.items) == 0 { + break + } + + h := m.VisibleItems() + + // If we've filtered down to nothing, clear the filter + if len(h) == 0 { + m.resetFiltering() + break + } + + m.FilterInput.Blur() + m.filterState = FilterApplied + m.updateKeybindings() + + if m.FilterInput.Value() == "" { + m.resetFiltering() + } + } + } + + // Update the filter text input component + newFilterInputModel, inputCmd := m.FilterInput.Update(msg) + filterChanged := m.FilterInput.Value() != newFilterInputModel.Value() + m.FilterInput = newFilterInputModel + cmds = append(cmds, inputCmd) + + // If the filtering input has changed, request updated filtering + if filterChanged { + cmds = append(cmds, filterItems(*m)) + m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") + } + + // Update pagination + m.updatePagination() + + return tea.Batch(cmds...) +} + +// ShortHelp returns bindings to show in the abbreviated help view. It's part +// of the help.KeyMap interface. +func (m Model) ShortHelp() []key.Binding { + kb := []key.Binding{ + m.KeyMap.CursorUp, + m.KeyMap.CursorDown, + } + + filtering := m.filterState == Filtering + + // If the delegate implements the help.KeyMap interface add the short help + // items to the short help after the cursor movement keys. + if !filtering { + if b, ok := m.delegate.(help.KeyMap); ok { + kb = append(kb, b.ShortHelp()...) + } + } + + kb = append(kb, + m.KeyMap.Filter, + m.KeyMap.ClearFilter, + m.KeyMap.AcceptWhileFiltering, + m.KeyMap.CancelWhileFiltering, + ) + + if !filtering && m.AdditionalShortHelpKeys != nil { + kb = append(kb, m.AdditionalShortHelpKeys()...) + } + + return append(kb, + m.KeyMap.Quit, + m.KeyMap.ShowFullHelp, + ) +} + +// FullHelp returns bindings to show the full help view. It's part of the +// help.KeyMap interface. +func (m Model) FullHelp() [][]key.Binding { + kb := [][]key.Binding{{ + m.KeyMap.CursorUp, + m.KeyMap.CursorDown, + m.KeyMap.NextPage, + m.KeyMap.PrevPage, + m.KeyMap.GoToStart, + m.KeyMap.GoToEnd, + }} + + filtering := m.filterState == Filtering + + // If the delegate implements the help.KeyMap interface add full help + // keybindings to a special section of the full help. + if !filtering { + if b, ok := m.delegate.(help.KeyMap); ok { + kb = append(kb, b.FullHelp()...) + } + } + + listLevelBindings := []key.Binding{ + m.KeyMap.Filter, + m.KeyMap.ClearFilter, + m.KeyMap.AcceptWhileFiltering, + m.KeyMap.CancelWhileFiltering, + } + + if !filtering && m.AdditionalFullHelpKeys != nil { + listLevelBindings = append(listLevelBindings, m.AdditionalFullHelpKeys()...) + } + + return append(kb, + listLevelBindings, + []key.Binding{ + m.KeyMap.Quit, + m.KeyMap.CloseFullHelp, + }) +} + +// View renders the component. +func (m Model) View() string { + var ( + sections []string + availHeight = m.height + ) + + if m.showTitle || (m.showFilter && m.filteringEnabled) { + v := m.titleView() + sections = append(sections, v) + availHeight -= lipgloss.Height(v) + } + + if m.showStatusBar { + v := m.statusView() + sections = append(sections, v) + availHeight -= lipgloss.Height(v) + } + + var pagination string + if m.showPagination { + pagination = m.paginationView() + availHeight -= lipgloss.Height(pagination) + } + + var help string + if m.showHelp { + help = m.helpView() + availHeight -= lipgloss.Height(help) + } + + content := lipgloss.NewStyle().Height(availHeight).Render(m.populatedView()) + sections = append(sections, content) + + if m.showPagination { + sections = append(sections, pagination) + } + + if m.showHelp { + sections = append(sections, help) + } + + return lipgloss.JoinVertical(lipgloss.Left, sections...) +} + +func (m Model) titleView() string { + var ( + view string + titleBarStyle = m.Styles.TitleBar.Copy() + + // We need to account for the size of the spinner, even if we don't + // render it, to reserve some space for it should we turn it on later. + spinnerView = m.spinnerView() + spinnerWidth = lipgloss.Width(spinnerView) + spinnerLeftGap = " " + spinnerOnLeft = titleBarStyle.GetPaddingLeft() >= spinnerWidth+lipgloss.Width(spinnerLeftGap) && m.showSpinner + ) + + // If the filter's showing, draw that. Otherwise draw the title. + if m.showFilter && m.filterState == Filtering { + view += m.FilterInput.View() + } else if m.showTitle { + if m.showSpinner && spinnerOnLeft { + view += spinnerView + spinnerLeftGap + titleBarGap := titleBarStyle.GetPaddingLeft() + titleBarStyle = titleBarStyle.PaddingLeft(titleBarGap - spinnerWidth - lipgloss.Width(spinnerLeftGap)) + } + + view += m.Styles.Title.Render(m.Title) + + // Status message + if m.filterState != Filtering { + view += " " + m.statusMessage + view = truncate.StringWithTail(view, uint(m.width-spinnerWidth), ellipsis) + } + } + + // Spinner + if m.showSpinner && !spinnerOnLeft { + // Place spinner on the right + availSpace := m.width - lipgloss.Width(m.Styles.TitleBar.Render(view)) + if availSpace > spinnerWidth { + view += strings.Repeat(" ", availSpace-spinnerWidth) + view += spinnerView + } + } + + if len(view) > 0 { + return titleBarStyle.Render(view) + } + return view +} + +func (m Model) statusView() string { + var status string + + totalItems := len(m.items) + visibleItems := len(m.VisibleItems()) + + var itemName string + if visibleItems != 1 { + itemName = m.itemNamePlural + } else { + itemName = m.itemNameSingular + } + + itemsDisplay := fmt.Sprintf("%d %s", visibleItems, itemName) + + if m.filterState == Filtering { + // Filter results + if visibleItems == 0 { + status = m.Styles.StatusEmpty.Render("Nothing matched") + } else { + status = itemsDisplay + } + } else if len(m.items) == 0 { + // Not filtering: no items. + status = m.Styles.StatusEmpty.Render("No " + m.itemNamePlural) + } else { + // Normal + filtered := m.FilterState() == FilterApplied + + if filtered { + f := strings.TrimSpace(m.FilterInput.Value()) + f = truncate.StringWithTail(f, 10, "…") + status += fmt.Sprintf("“%s” ", f) + } + + status += itemsDisplay + } + + numFiltered := totalItems - visibleItems + if numFiltered > 0 { + status += m.Styles.DividerDot.String() + status += m.Styles.StatusBarFilterCount.Render(fmt.Sprintf("%d filtered", numFiltered)) + } + + return m.Styles.StatusBar.Render(status) +} + +func (m Model) paginationView() string { + if m.Paginator.TotalPages < 2 { //nolint:gomnd + return "" + } + + s := m.Paginator.View() + + // If the dot pagination is wider than the width of the window + // use the arabic paginator. + if ansi.PrintableRuneWidth(s) > m.width { + m.Paginator.Type = paginator.Arabic + s = m.Styles.ArabicPagination.Render(m.Paginator.View()) + } + + style := m.Styles.PaginationStyle + if m.delegate.Spacing() == 0 && style.GetMarginTop() == 0 { + style = style.Copy().MarginTop(1) + } + + return style.Render(s) +} + +func (m Model) populatedView() string { + items := m.VisibleItems() + + var b strings.Builder + + // Empty states + if len(items) == 0 { + if m.filterState == Filtering { + return "" + } + return m.Styles.NoItems.Render("No " + m.itemNamePlural + " found.") + } + + if len(items) > 0 { + start, end := m.Paginator.GetSliceBounds(len(items)) + docs := items[start:end] + + for i, item := range docs { + m.delegate.Render(&b, m, i+start, item) + if i != len(docs)-1 { + fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1)) + } + } + } + + // If there aren't enough items to fill up this page (always the last page) + // then we need to add some newlines to fill up the space where items would + // have been. + itemsOnPage := m.Paginator.ItemsOnPage(len(items)) + if itemsOnPage < m.Paginator.PerPage { + n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing()) + if len(items) == 0 { + n -= m.delegate.Height() - 1 + } + fmt.Fprint(&b, strings.Repeat("\n", n)) + } + + return b.String() +} + +func (m Model) helpView() string { + return m.Styles.HelpStyle.Render(m.Help.View(m)) +} + +func (m Model) spinnerView() string { + return m.spinner.View() +} + +func filterItems(m Model) tea.Cmd { + return func() tea.Msg { + if m.FilterInput.Value() == "" || m.filterState == Unfiltered { + return FilterMatchesMsg(m.itemsAsFilterItems()) // return nothing + } + + targets := []string{} + items := m.items + + for _, t := range items { + targets = append(targets, t.FilterValue()) + } + + filterMatches := []filteredItem{} + for _, r := range m.Filter(m.FilterInput.Value(), targets) { + filterMatches = append(filterMatches, filteredItem{ + item: items[r.Index], + matches: r.MatchedIndexes, + }) + } + + return FilterMatchesMsg(filterMatches) + } +} + +func insertItemIntoSlice(items []Item, item Item, index int) []Item { + if items == nil { + return []Item{item} + } + if index >= len(items) { + return append(items, item) + } + + index = max(0, index) + + items = append(items, nil) + copy(items[index+1:], items[index:]) + items[index] = item + return items +} + +// Remove an item from a slice of items at the given index. This runs in O(n). +func removeItemFromSlice(i []Item, index int) []Item { + if index >= len(i) { + return i // noop + } + copy(i[index:], i[index+1:]) + i[len(i)-1] = nil + return i[:len(i)-1] +} + +func removeFilterMatchFromSlice(i []filteredItem, index int) []filteredItem { + if index >= len(i) { + return i // noop + } + copy(i[index:], i[index+1:]) + i[len(i)-1] = filteredItem{} + return i[:len(i)-1] +} + +func countEnabledBindings(groups [][]key.Binding) (agg int) { + for _, group := range groups { + for _, kb := range group { + if kb.Enabled() { + agg++ + } + } + } + return agg +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/vendor/github.com/charmbracelet/bubbles/list/style.go b/vendor/github.com/charmbracelet/bubbles/list/style.go new file mode 100644 index 0000000..e4451f8 --- /dev/null +++ b/vendor/github.com/charmbracelet/bubbles/list/style.go @@ -0,0 +1,99 @@ +package list + +import ( + "github.com/charmbracelet/lipgloss" +) + +const ( + bullet = "•" + ellipsis = "…" +) + +// Styles contains style definitions for this list component. By default, these +// values are generated by DefaultStyles. +type Styles struct { + TitleBar lipgloss.Style + Title lipgloss.Style + Spinner lipgloss.Style + FilterPrompt lipgloss.Style + FilterCursor lipgloss.Style + + // Default styling for matched characters in a filter. This can be + // overridden by delegates. + DefaultFilterCharacterMatch lipgloss.Style + + StatusBar lipgloss.Style + StatusEmpty lipgloss.Style + StatusBarActiveFilter lipgloss.Style + StatusBarFilterCount lipgloss.Style + + NoItems lipgloss.Style + + PaginationStyle lipgloss.Style + HelpStyle lipgloss.Style + + // Styled characters. + ActivePaginationDot lipgloss.Style + InactivePaginationDot lipgloss.Style + ArabicPagination lipgloss.Style + DividerDot lipgloss.Style +} + +// DefaultStyles returns a set of default style definitions for this list +// component. +func DefaultStyles() (s Styles) { + verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} + subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"} + + s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2) + + s.Title = lipgloss.NewStyle(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + + s.Spinner = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"}) + + s.FilterPrompt = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}) + + s.FilterCursor = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}) + + s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true) + + s.StatusBar = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). + Padding(0, 0, 1, 2) + + s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor) + + s.StatusBarActiveFilter = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) + + s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor) + + s.NoItems = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}) + + s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor) + + s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd + + s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) + + s.ActivePaginationDot = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}). + SetString(bullet) + + s.InactivePaginationDot = lipgloss.NewStyle(). + Foreground(verySubduedColor). + SetString(bullet) + + s.DividerDot = lipgloss.NewStyle(). + Foreground(verySubduedColor). + SetString(" " + bullet + " ") + + return s +} diff --git a/vendor/github.com/charmbracelet/bubbles/paginator/paginator.go b/vendor/github.com/charmbracelet/bubbles/paginator/paginator.go new file mode 100644 index 0000000..a84819a --- /dev/null +++ b/vendor/github.com/charmbracelet/bubbles/paginator/paginator.go @@ -0,0 +1,202 @@ +// Package paginator provides a Bubble Tea package for calulating pagination +// and rendering pagination info. Note that this package does not render actual +// pages: it's purely for handling keystrokes related to pagination, and +// rendering pagination status. +package paginator + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +// Type specifies the way we render pagination. +type Type int + +// Pagination rendering options. +const ( + Arabic Type = iota + Dots +) + +// Model is the Bubble Tea model for this user interface. +type Model struct { + Type Type + Page int + PerPage int + TotalPages int + ActiveDot string + InactiveDot string + ArabicFormat string + UsePgUpPgDownKeys bool + UseLeftRightKeys bool + UseUpDownKeys bool + UseHLKeys bool + UseJKKeys bool +} + +// SetTotalPages is a helper function for calculating the total number of pages +// from a given number of items. It's use is optional since this pager can be +// used for other things beyond navigating sets. Note that it both returns the +// number of total pages and alters the model. +func (m *Model) SetTotalPages(items int) int { + if items < 1 { + return m.TotalPages + } + n := items / m.PerPage + if items%m.PerPage > 0 { + n++ + } + m.TotalPages = n + return n +} + +// ItemsOnPage is a helper function for returning the numer of items on the +// current page given the total numer of items passed as an argument. +func (m Model) ItemsOnPage(totalItems int) int { + if totalItems < 1 { + return 0 + } + start, end := m.GetSliceBounds(totalItems) + return end - start +} + +// GetSliceBounds is a helper function for paginating slices. Pass the length +// of the slice you're rendering and you'll receive the start and end bounds +// corresponding the to pagination. For example: +// +// bunchOfStuff := []stuff{...} +// start, end := model.GetSliceBounds(len(bunchOfStuff)) +// sliceToRender := bunchOfStuff[start:end] +// +func (m *Model) GetSliceBounds(length int) (start int, end int) { + start = m.Page * m.PerPage + end = min(m.Page*m.PerPage+m.PerPage, length) + return start, end +} + +// PrevPage is a number function for navigating one page backward. It will not +// page beyond the first page (i.e. page 0). +func (m *Model) PrevPage() { + if m.Page > 0 { + m.Page-- + } +} + +// NextPage is a helper function for navigating one page forward. It will not +// page beyond the last page (i.e. totalPages - 1). +func (m *Model) NextPage() { + if !m.OnLastPage() { + m.Page++ + } +} + +// OnLastPage returns whether or not we're on the last page. +func (m Model) OnLastPage() bool { + return m.Page == m.TotalPages-1 +} + +// New creates a new model with defaults. +func New() Model { + return Model{ + Type: Arabic, + Page: 0, + PerPage: 1, + TotalPages: 1, + ActiveDot: "•", + InactiveDot: "○", + ArabicFormat: "%d/%d", + UsePgUpPgDownKeys: true, + UseLeftRightKeys: true, + UseUpDownKeys: false, + UseHLKeys: true, + UseJKKeys: false, + } +} + +// NewModel creates a new model with defaults. +// +// Deprecated. Use New instead. +var NewModel = New + +// Update is the Tea update function which binds keystrokes to pagination. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if m.UsePgUpPgDownKeys { + switch msg.String() { + case "pgup": + m.PrevPage() + case "pgdown": + m.NextPage() + } + } + if m.UseLeftRightKeys { + switch msg.String() { + case "left": + m.PrevPage() + case "right": + m.NextPage() + } + } + if m.UseUpDownKeys { + switch msg.String() { + case "up": + m.PrevPage() + case "down": + m.NextPage() + } + } + if m.UseHLKeys { + switch msg.String() { + case "h": + m.PrevPage() + case "l": + m.NextPage() + } + } + if m.UseJKKeys { + switch msg.String() { + case "j": + m.PrevPage() + case "k": + m.NextPage() + } + } + } + + return m, nil +} + +// View renders the pagination to a string. +func (m Model) View() string { + switch m.Type { + case Dots: + return m.dotsView() + default: + return m.arabicView() + } +} + +func (m Model) dotsView() string { + var s string + for i := 0; i < m.TotalPages; i++ { + if i == m.Page { + s += m.ActiveDot + continue + } + s += m.InactiveDot + } + return s +} + +func (m Model) arabicView() string { + return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/vendor/github.com/charmbracelet/bubbles/textinput/textinput.go b/vendor/github.com/charmbracelet/bubbles/textinput/textinput.go new file mode 100644 index 0000000..9692ed7 --- /dev/null +++ b/vendor/github.com/charmbracelet/bubbles/textinput/textinput.go @@ -0,0 +1,860 @@ +package textinput + +import ( + "context" + "strings" + "sync" + "time" + "unicode" + + "github.com/atotto/clipboard" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + rw "github.com/mattn/go-runewidth" +) + +const defaultBlinkSpeed = time.Millisecond * 530 + +// Internal ID management for text inputs. Necessary for blink integrity when +// multiple text inputs are involved. +var ( + lastID int + idMtx sync.Mutex +) + +// Return the next ID we should use on the Model. +func nextID() int { + idMtx.Lock() + defer idMtx.Unlock() + lastID++ + return lastID +} + +// initialBlinkMsg initializes cursor blinking. +type initialBlinkMsg struct{} + +// blinkMsg signals that the cursor should blink. It contains metadata that +// allows us to tell if the blink message is the one we're expecting. +type blinkMsg struct { + id int + tag int +} + +// blinkCanceled is sent when a blink operation is canceled. +type blinkCanceled struct{} + +// Internal messages for clipboard operations. +type pasteMsg string +type pasteErrMsg struct{ error } + +// EchoMode sets the input behavior of the text input field. +type EchoMode int + +const ( + // EchoNormal displays text as is. This is the default behavior. + EchoNormal EchoMode = iota + + // EchoPassword displays the EchoCharacter mask instead of actual + // characters. This is commonly used for password fields. + EchoPassword + + // EchoNone displays nothing as characters are entered. This is commonly + // seen for password fields on the command line. + EchoNone + + // EchoOnEdit. +) + +// blinkCtx manages cursor blinking. +type blinkCtx struct { + ctx context.Context + cancel context.CancelFunc +} + +// CursorMode describes the behavior of the cursor. +type CursorMode int + +// Available cursor modes. +const ( + CursorBlink CursorMode = iota + CursorStatic + CursorHide +) + +// String returns a the cursor mode in a human-readable format. This method is +// provisional and for informational purposes only. +func (c CursorMode) String() string { + return [...]string{ + "blink", + "static", + "hidden", + }[c] +} + +// ValidateFunc is a function that returns an error if the input is invalid. +type ValidateFunc func(string) error + +// Model is the Bubble Tea model for this text input element. +type Model struct { + Err error + + // General settings. + Prompt string + Placeholder string + BlinkSpeed time.Duration + EchoMode EchoMode + EchoCharacter rune + + // Styles. These will be applied as inline styles. + // + // For an introduction to styling with Lip Gloss see: + // https://github.com/charmbracelet/lipgloss + PromptStyle lipgloss.Style + TextStyle lipgloss.Style + BackgroundStyle lipgloss.Style + PlaceholderStyle lipgloss.Style + CursorStyle lipgloss.Style + + // CharLimit is the maximum amount of characters this input element will + // accept. If 0 or less, there's no limit. + CharLimit int + + // Width is the maximum number of characters that can be displayed at once. + // It essentially treats the text field like a horizontally scrolling + // viewport. If 0 or less this setting is ignored. + Width int + + // The ID of this Model as it relates to other textinput Models. + id int + + // The ID of the blink message we're expecting to receive. + blinkTag int + + // Underlying text value. + value []rune + + // focus indicates whether user input focus should be on this input + // component. When false, ignore keyboard input and hide the cursor. + focus bool + + // Cursor blink state. + blink bool + + // Cursor position. + pos int + + // Used to emulate a viewport when width is set and the content is + // overflowing. + offset int + offsetRight int + + // Used to manage cursor blink + blinkCtx *blinkCtx + + // cursorMode determines the behavior of the cursor + cursorMode CursorMode + + // Validate is a function that checks whether or not the text within the + // input is valid. If it is not valid, the `Err` field will be set to the + // error returned by the function. If the function is not defined, all + // input is considered valid. + Validate ValidateFunc +} + +// New creates a new model with default settings. +func New() Model { + return Model{ + Prompt: "> ", + BlinkSpeed: defaultBlinkSpeed, + EchoCharacter: '*', + CharLimit: 0, + PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + + id: nextID(), + value: nil, + focus: false, + blink: true, + pos: 0, + cursorMode: CursorBlink, + + blinkCtx: &blinkCtx{ + ctx: context.Background(), + }, + } +} + +// NewModel creates a new model with default settings. +// +// Deprecated. Use New instead. +var NewModel = New + +// SetValue sets the value of the text input. +func (m *Model) SetValue(s string) { + if m.Validate != nil { + if err := m.Validate(s); err != nil { + m.Err = err + return + } + } + + m.Err = nil + + runes := []rune(s) + if m.CharLimit > 0 && len(runes) > m.CharLimit { + m.value = runes[:m.CharLimit] + } else { + m.value = runes + } + if m.pos == 0 || m.pos > len(m.value) { + m.setCursor(len(m.value)) + } + m.handleOverflow() +} + +// Value returns the value of the text input. +func (m Model) Value() string { + return string(m.value) +} + +// Cursor returns the cursor position. +func (m Model) Cursor() int { + return m.pos +} + +// Blink returns whether or not to draw the cursor. +func (m Model) Blink() bool { + return m.blink +} + +// SetCursor moves the cursor to the given position. If the position is +// out of bounds the cursor will be moved to the start or end accordingly. +func (m *Model) SetCursor(pos int) { + m.setCursor(pos) +} + +// setCursor moves the cursor to the given position and returns whether or not +// the cursor blink should be reset. If the position is out of bounds the +// cursor will be moved to the start or end accordingly. +func (m *Model) setCursor(pos int) bool { + m.pos = clamp(pos, 0, len(m.value)) + m.handleOverflow() + + // Show the cursor unless it's been explicitly hidden + m.blink = m.cursorMode == CursorHide + + // Reset cursor blink if necessary + return m.cursorMode == CursorBlink +} + +// CursorStart moves the cursor to the start of the input field. +func (m *Model) CursorStart() { + m.cursorStart() +} + +// cursorStart moves the cursor to the start of the input field and returns +// whether or not the curosr blink should be reset. +func (m *Model) cursorStart() bool { + return m.setCursor(0) +} + +// CursorEnd moves the cursor to the end of the input field. +func (m *Model) CursorEnd() { + m.cursorEnd() +} + +// CursorMode returns the model's cursor mode. For available cursor modes, see +// type CursorMode. +func (m Model) CursorMode() CursorMode { + return m.cursorMode +} + +// SetCursorMode sets the model's cursor mode. This method returns a command. +// +// For available cursor modes, see type CursorMode. +func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd { + m.cursorMode = mode + m.blink = m.cursorMode == CursorHide || !m.focus + if mode == CursorBlink { + return Blink + } + return nil +} + +// cursorEnd moves the cursor to the end of the input field and returns whether +// the cursor should blink should reset. +func (m *Model) cursorEnd() bool { + return m.setCursor(len(m.value)) +} + +// Focused returns the focus state on the model. +func (m Model) Focused() bool { + return m.focus +} + +// Focus sets the focus state on the model. When the model is in focus it can +// receive keyboard input and the cursor will be hidden. +func (m *Model) Focus() tea.Cmd { + m.focus = true + m.blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it + + if m.cursorMode == CursorBlink && m.focus { + return m.blinkCmd() + } + return nil +} + +// Blur removes the focus state on the model. When the model is blurred it can +// not receive keyboard input and the cursor will be hidden. +func (m *Model) Blur() { + m.focus = false + m.blink = true +} + +// Reset sets the input to its default state with no input. Returns whether +// or not the cursor blink should reset. +func (m *Model) Reset() bool { + m.value = nil + return m.setCursor(0) +} + +// handle a clipboard paste event, if supported. Returns whether or not the +// cursor blink should reset. +func (m *Model) handlePaste(v string) bool { + paste := []rune(v) + + var availSpace int + if m.CharLimit > 0 { + availSpace = m.CharLimit - len(m.value) + } + + // If the char limit's been reached cancel + if m.CharLimit > 0 && availSpace <= 0 { + return false + } + + // If there's not enough space to paste the whole thing cut the pasted + // runes down so they'll fit + if m.CharLimit > 0 && availSpace < len(paste) { + paste = paste[:len(paste)-availSpace] + } + + // Stuff before and after the cursor + head := m.value[:m.pos] + tailSrc := m.value[m.pos:] + tail := make([]rune, len(tailSrc)) + copy(tail, tailSrc) + + oldPos := m.pos + + // Insert pasted runes + for _, r := range paste { + head = append(head, r) + m.pos++ + if m.CharLimit > 0 { + availSpace-- + if availSpace <= 0 { + break + } + } + } + + // Put it all back together + value := append(head, tail...) + m.SetValue(string(value)) + + if m.Err != nil { + m.pos = oldPos + } + + // Reset blink state if necessary and run overflow checks + return m.setCursor(m.pos) +} + +// If a max width is defined, perform some logic to treat the visible area +// as a horizontally scrolling viewport. +func (m *Model) handleOverflow() { + if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width { + m.offset = 0 + m.offsetRight = len(m.value) + return + } + + // Correct right offset if we've deleted characters + m.offsetRight = min(m.offsetRight, len(m.value)) + + if m.pos < m.offset { + m.offset = m.pos + + w := 0 + i := 0 + runes := m.value[m.offset:] + + for i < len(runes) && w <= m.Width { + w += rw.RuneWidth(runes[i]) + if w <= m.Width+1 { + i++ + } + } + + m.offsetRight = m.offset + i + } else if m.pos >= m.offsetRight { + m.offsetRight = m.pos + + w := 0 + runes := m.value[:m.offsetRight] + i := len(runes) - 1 + + for i > 0 && w < m.Width { + w += rw.RuneWidth(runes[i]) + if w <= m.Width { + i-- + } + } + + m.offset = m.offsetRight - (len(runes) - 1 - i) + } +} + +// deleteBeforeCursor deletes all text before the cursor. Returns whether or +// not the cursor blink should be reset. +func (m *Model) deleteBeforeCursor() bool { + m.value = m.value[m.pos:] + m.offset = 0 + return m.setCursor(0) +} + +// deleteAfterCursor deletes all text after the cursor. Returns whether or not +// the cursor blink should be reset. If input is masked delete everything after +// the cursor so as not to reveal word breaks in the masked input. +func (m *Model) deleteAfterCursor() bool { + m.value = m.value[:m.pos] + return m.setCursor(len(m.value)) +} + +// deleteWordLeft deletes the word left to the cursor. Returns whether or not +// the cursor blink should be reset. +func (m *Model) deleteWordLeft() bool { + if m.pos == 0 || len(m.value) == 0 { + return false + } + + if m.EchoMode != EchoNormal { + return m.deleteBeforeCursor() + } + + // Linter note: it's critical that we acquire the initial cursor position + // here prior to altering it via SetCursor() below. As such, moving this + // call into the corresponding if clause does not apply here. + oldPos := m.pos //nolint:ifshort + + blink := m.setCursor(m.pos - 1) + for unicode.IsSpace(m.value[m.pos]) { + if m.pos <= 0 { + break + } + // ignore series of whitespace before cursor + blink = m.setCursor(m.pos - 1) + } + + for m.pos > 0 { + if !unicode.IsSpace(m.value[m.pos]) { + blink = m.setCursor(m.pos - 1) + } else { + if m.pos > 0 { + // keep the previous space + blink = m.setCursor(m.pos + 1) + } + break + } + } + + if oldPos > len(m.value) { + m.value = m.value[:m.pos] + } else { + m.value = append(m.value[:m.pos], m.value[oldPos:]...) + } + + return blink +} + +// deleteWordRight deletes the word right to the cursor. Returns whether or not +// the cursor blink should be reset. If input is masked delete everything after +// the cursor so as not to reveal word breaks in the masked input. +func (m *Model) deleteWordRight() bool { + if m.pos >= len(m.value) || len(m.value) == 0 { + return false + } + + if m.EchoMode != EchoNormal { + return m.deleteAfterCursor() + } + + oldPos := m.pos + m.setCursor(m.pos + 1) + for unicode.IsSpace(m.value[m.pos]) { + // ignore series of whitespace after cursor + m.setCursor(m.pos + 1) + + if m.pos >= len(m.value) { + break + } + } + + for m.pos < len(m.value) { + if !unicode.IsSpace(m.value[m.pos]) { + m.setCursor(m.pos + 1) + } else { + break + } + } + + if m.pos > len(m.value) { + m.value = m.value[:oldPos] + } else { + m.value = append(m.value[:oldPos], m.value[m.pos:]...) + } + + return m.setCursor(oldPos) +} + +// wordLeft moves the cursor one word to the left. Returns whether or not the +// cursor blink should be reset. If input is masked, move input to the start +// so as not to reveal word breaks in the masked input. +func (m *Model) wordLeft() bool { + if m.pos == 0 || len(m.value) == 0 { + return false + } + + if m.EchoMode != EchoNormal { + return m.cursorStart() + } + + blink := false + i := m.pos - 1 + for i >= 0 { + if unicode.IsSpace(m.value[i]) { + blink = m.setCursor(m.pos - 1) + i-- + } else { + break + } + } + + for i >= 0 { + if !unicode.IsSpace(m.value[i]) { + blink = m.setCursor(m.pos - 1) + i-- + } else { + break + } + } + + return blink +} + +// wordRight moves the cursor one word to the right. Returns whether or not the +// cursor blink should be reset. If the input is masked, move input to the end +// so as not to reveal word breaks in the masked input. +func (m *Model) wordRight() bool { + if m.pos >= len(m.value) || len(m.value) == 0 { + return false + } + + if m.EchoMode != EchoNormal { + return m.cursorEnd() + } + + blink := false + i := m.pos + for i < len(m.value) { + if unicode.IsSpace(m.value[i]) { + blink = m.setCursor(m.pos + 1) + i++ + } else { + break + } + } + + for i < len(m.value) { + if !unicode.IsSpace(m.value[i]) { + blink = m.setCursor(m.pos + 1) + i++ + } else { + break + } + } + + return blink +} + +func (m Model) echoTransform(v string) string { + switch m.EchoMode { + case EchoPassword: + return strings.Repeat(string(m.EchoCharacter), rw.StringWidth(v)) + case EchoNone: + return "" + + default: + return v + } +} + +// Update is the Bubble Tea update loop. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if !m.focus { + m.blink = true + return m, nil + } + + var resetBlink bool + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyBackspace, tea.KeyCtrlH: // delete character before cursor + m.Err = nil + + if msg.Alt { + resetBlink = m.deleteWordLeft() + } else { + if len(m.value) > 0 { + m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) + if m.pos > 0 { + resetBlink = m.setCursor(m.pos - 1) + } + } + } + case tea.KeyLeft, tea.KeyCtrlB: + if msg.Alt { // alt+left arrow, back one word + resetBlink = m.wordLeft() + break + } + if m.pos > 0 { // left arrow, ^F, back one character + resetBlink = m.setCursor(m.pos - 1) + } + case tea.KeyRight, tea.KeyCtrlF: + if msg.Alt { // alt+right arrow, forward one word + resetBlink = m.wordRight() + break + } + if m.pos < len(m.value) { // right arrow, ^F, forward one character + resetBlink = m.setCursor(m.pos + 1) + } + case tea.KeyCtrlW: // ^W, delete word left of cursor + resetBlink = m.deleteWordLeft() + case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning + resetBlink = m.cursorStart() + case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor + if len(m.value) > 0 && m.pos < len(m.value) { + m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) + } + case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end + resetBlink = m.cursorEnd() + case tea.KeyCtrlK: // ^K, kill text after cursor + resetBlink = m.deleteAfterCursor() + case tea.KeyCtrlU: // ^U, kill text before cursor + resetBlink = m.deleteBeforeCursor() + case tea.KeyCtrlV: // ^V paste + return m, Paste + case tea.KeyRunes, tea.KeySpace: // input regular characters + if msg.Alt && len(msg.Runes) == 1 { + if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor + resetBlink = m.deleteWordRight() + break + } + if msg.Runes[0] == 'b' { // alt+b, back one word + resetBlink = m.wordLeft() + break + } + if msg.Runes[0] == 'f' { // alt+f, forward one word + resetBlink = m.wordRight() + break + } + } + + // Input a regular character + if m.CharLimit <= 0 || len(m.value) < m.CharLimit { + runes := msg.Runes + + value := make([]rune, len(m.value)) + copy(value, m.value) + value = append(value[:m.pos], append(runes, value[m.pos:]...)...) + m.SetValue(string(value)) + if m.Err == nil { + resetBlink = m.setCursor(m.pos + len(runes)) + } + } + } + + case initialBlinkMsg: + // We accept all initialBlinkMsgs genrated by the Blink command. + + if m.cursorMode != CursorBlink || !m.focus { + return m, nil + } + + cmd := m.blinkCmd() + return m, cmd + + case blinkMsg: + // We're choosy about whether to accept blinkMsgs so that our cursor + // only exactly when it should. + + // Is this model blinkable? + if m.cursorMode != CursorBlink || !m.focus { + return m, nil + } + + // Were we expecting this blink message? + if msg.id != m.id || msg.tag != m.blinkTag { + return m, nil + } + + var cmd tea.Cmd + if m.cursorMode == CursorBlink { + m.blink = !m.blink + cmd = m.blinkCmd() + } + return m, cmd + + case blinkCanceled: // no-op + return m, nil + + case pasteMsg: + resetBlink = m.handlePaste(string(msg)) + + case pasteErrMsg: + m.Err = msg + } + + var cmd tea.Cmd + if resetBlink { + cmd = m.blinkCmd() + } + + m.handleOverflow() + return m, cmd +} + +// View renders the textinput in its current state. +func (m Model) View() string { + // Placeholder text + if len(m.value) == 0 && m.Placeholder != "" { + return m.placeholderView() + } + + styleText := m.TextStyle.Inline(true).Render + + value := m.value[m.offset:m.offsetRight] + pos := max(0, m.pos-m.offset) + v := styleText(m.echoTransform(string(value[:pos]))) + + if pos < len(value) { + v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it + v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor + } else { + v += m.cursorView(" ") + } + + // If a max width and background color were set fill the empty spaces with + // the background color. + valWidth := rw.StringWidth(string(value)) + if m.Width > 0 && valWidth <= m.Width { + padding := max(0, m.Width-valWidth) + if valWidth+padding <= m.Width && pos < len(value) { + padding++ + } + v += styleText(strings.Repeat(" ", padding)) + } + + return m.PromptStyle.Render(m.Prompt) + v +} + +// placeholderView returns the prompt and placeholder view, if any. +func (m Model) placeholderView() string { + var ( + v string + p = m.Placeholder + style = m.PlaceholderStyle.Inline(true).Render + ) + + // Cursor + if m.blink { + v += m.cursorView(style(p[:1])) + } else { + v += m.cursorView(p[:1]) + } + + // The rest of the placeholder text + v += style(p[1:]) + + return m.PromptStyle.Render(m.Prompt) + v +} + +// cursorView styles the cursor. +func (m Model) cursorView(v string) string { + if m.blink { + return m.TextStyle.Render(v) + } + return m.CursorStyle.Inline(true).Reverse(true).Render(v) +} + +// blinkCmd is an internal command used to manage cursor blinking. +func (m *Model) blinkCmd() tea.Cmd { + if m.cursorMode != CursorBlink { + return nil + } + + if m.blinkCtx != nil && m.blinkCtx.cancel != nil { + m.blinkCtx.cancel() + } + + ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed) + m.blinkCtx.cancel = cancel + + m.blinkTag++ + + return func() tea.Msg { + defer cancel() + <-ctx.Done() + if ctx.Err() == context.DeadlineExceeded { + return blinkMsg{id: m.id, tag: m.blinkTag} + } + return blinkCanceled{} + } +} + +// Blink is a command used to initialize cursor blinking. +func Blink() tea.Msg { + return initialBlinkMsg{} +} + +// Paste is a command for pasting from the clipboard into the text input. +func Paste() tea.Msg { + str, err := clipboard.ReadAll() + if err != nil { + return pasteErrMsg{err} + } + return pasteMsg(str) +} + +func clamp(v, low, high int) int { + if high < low { + low, high = high, low + } + return min(high, max(low, v)) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/vendor/github.com/muesli/ansi/README.md b/vendor/github.com/muesli/ansi/README.md index cb28c65..f9d0fe9 100644 --- a/vendor/github.com/muesli/ansi/README.md +++ b/vendor/github.com/muesli/ansi/README.md @@ -1,2 +1,31 @@ # ansi + +[![Latest Release](https://img.shields.io/github/release/muesli/ansi.svg)](https://github.com/muesli/ansi/releases) +[![Build Status](https://github.com/muesli/ansi/workflows/build/badge.svg)](https://github.com/muesli/ansi/actions) +[![Coverage Status](https://coveralls.io/repos/github/muesli/ansi/badge.svg?branch=master)](https://coveralls.io/github/muesli/ansi?branch=master) +[![Go ReportCard](https://goreportcard.com/badge/muesli/ansi)](https://goreportcard.com/report/muesli/ansi) +[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/muesli/ansi) + Raw ANSI sequence helpers + +## ANSI Writer + +```go +import "github.com/muesli/ansi" + +w := ansi.Writer{Forward: os.Stdout} +w.Write([]byte("\x1b[31mHello, world!\x1b[0m")) +w.Close() +``` + +## Compressor + +The ANSI compressor eliminates unnecessary/redundant ANSI sequences. + +```go +import "github.com/muesli/ansi/compressor" + +w := compressor.Writer{Forward: os.Stdout} +w.Write([]byte("\x1b[31mHello, world!\x1b[0m")) +w.Close() +``` diff --git a/vendor/github.com/sahilm/fuzzy/.editorconfig b/vendor/github.com/sahilm/fuzzy/.editorconfig new file mode 100644 index 0000000..8a8f6a5 --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/.editorconfig @@ -0,0 +1,18 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.sh] +indent_size = 4 + +[Makefile] +indent_style = tab +indent_size = 4 + +[*.go] +indent_style = tab +indent_size = 4 diff --git a/vendor/github.com/sahilm/fuzzy/.gitignore b/vendor/github.com/sahilm/fuzzy/.gitignore new file mode 100644 index 0000000..d6c59ee --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/.gitignore @@ -0,0 +1,2 @@ +vendor/ +coverage/ diff --git a/vendor/github.com/sahilm/fuzzy/.travis.yml b/vendor/github.com/sahilm/fuzzy/.travis.yml new file mode 100644 index 0000000..6756d80 --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/.travis.yml @@ -0,0 +1,5 @@ +language: go +go: + - 1.x +script: + - make diff --git a/vendor/github.com/sahilm/fuzzy/CONTRIBUTING.md b/vendor/github.com/sahilm/fuzzy/CONTRIBUTING.md new file mode 100644 index 0000000..7068ce1 --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/CONTRIBUTING.md @@ -0,0 +1 @@ +Everyone is welcome to contribute. Please send me a pull request or file an issue. I promise to respond promptly. diff --git a/vendor/github.com/sahilm/fuzzy/Gopkg.lock b/vendor/github.com/sahilm/fuzzy/Gopkg.lock new file mode 100644 index 0000000..6e3a7fe --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/Gopkg.lock @@ -0,0 +1,20 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "master" + digest = "1:ee97ec8a00b2424570c1ce53d7b410e96fbd4c241b29df134276ff6aa3750335" + name = "github.com/kylelemons/godebug" + packages = [ + "diff", + "pretty", + ] + pruneopts = "" + revision = "d65d576e9348f5982d7f6d83682b694e731a45c6" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = ["github.com/kylelemons/godebug/pretty"] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/vendor/github.com/sahilm/fuzzy/Gopkg.toml b/vendor/github.com/sahilm/fuzzy/Gopkg.toml new file mode 100644 index 0000000..8f96b11 --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/Gopkg.toml @@ -0,0 +1,4 @@ +# Test dependency +[[constraint]] + branch = "master" + name = "github.com/kylelemons/godebug" diff --git a/vendor/github.com/sahilm/fuzzy/LICENSE b/vendor/github.com/sahilm/fuzzy/LICENSE new file mode 100644 index 0000000..f848719 --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Sahil Muthoo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/sahilm/fuzzy/Makefile b/vendor/github.com/sahilm/fuzzy/Makefile new file mode 100644 index 0000000..7fa2be4 --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/Makefile @@ -0,0 +1,57 @@ +.PHONY: all +all: setup lint test + +.PHONY: test +test: setup + go test -bench ./... + +.PHONY: cover +cover: setup + mkdir -p coverage + gocov test ./... | gocov-html > coverage/coverage.html + +sources = $(shell find . -name '*.go' -not -path './vendor/*') +.PHONY: goimports +goimports: setup + goimports -w $(sources) + +.PHONY: lint +lint: setup + gometalinter ./... --enable=goimports --disable=gocyclo --vendor -t + +.PHONY: install +install: setup + go install + +BIN_DIR := $(GOPATH)/bin +GOIMPORTS := $(BIN_DIR)/goimports +GOMETALINTER := $(BIN_DIR)/gometalinter +DEP := $(BIN_DIR)/dep +GOCOV := $(BIN_DIR)/gocov +GOCOV_HTML := $(BIN_DIR)/gocov-html + +$(GOIMPORTS): + go get -u golang.org/x/tools/cmd/goimports + +$(GOMETALINTER): + go get -u github.com/alecthomas/gometalinter + gometalinter --install &> /dev/null + +$(GOCOV): + go get -u github.com/axw/gocov/gocov + +$(GOCOV_HTML): + go get -u gopkg.in/matm/v1/gocov-html + +$(DEP): + go get -u github.com/golang/dep/cmd/dep + +tools: $(GOIMPORTS) $(GOMETALINTER) $(GOCOV) $(GOCOV_HTML) $(DEP) + +vendor: $(DEP) + dep ensure + +setup: tools vendor + +updatedeps: + dep ensure -update diff --git a/vendor/github.com/sahilm/fuzzy/README.md b/vendor/github.com/sahilm/fuzzy/README.md new file mode 100644 index 0000000..c632da5 --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/README.md @@ -0,0 +1,184 @@ +gopher looking for stuff gopher found stuff + +# fuzzy +[![Build Status](https://travis-ci.org/sahilm/fuzzy.svg?branch=master)](https://travis-ci.org/sahilm/fuzzy) +[![Documentation](https://godoc.org/github.com/sahilm/fuzzy?status.svg)](https://godoc.org/github.com/sahilm/fuzzy) + +Go library that provides fuzzy string matching optimized for filenames and code symbols in the style of Sublime Text, +VSCode, IntelliJ IDEA et al. This library is external dependency-free. It only depends on the Go standard library. + +## Features + +- Intuitive matching. Results are returned in descending order of match quality. Quality is determined by: + - The first character in the pattern matches the first character in the match string. + - The matched character is camel cased. + - The matched character follows a separator such as an underscore character. + - The matched character is adjacent to a previous match. + +- Speed. Matches are returned in milliseconds. It's perfect for interactive search boxes. + +- The positions of matches is returned. Allows you to highlight matching characters. + +- Unicode aware. + +## Demo + +Here is a [demo](_example/main.go) of matching various patterns against ~16K files from the Unreal Engine 4 codebase. + +![demo](assets/demo.gif) + +You can run the demo yourself like so: + +``` +cd _example/ +go get github.com/jroimartin/gocui +go run main.go +``` + +## Usage + +The following example prints out matches with the matched chars in bold. + +```go +package main + +import ( + "fmt" + + "github.com/sahilm/fuzzy" +) + +func main() { + const bold = "\033[1m%s\033[0m" + pattern := "mnr" + data := []string{"game.cpp", "moduleNameResolver.ts", "my name is_Ramsey"} + + matches := fuzzy.Find(pattern, data) + + for _, match := range matches { + for i := 0; i < len(match.Str); i++ { + if contains(i, match.MatchedIndexes) { + fmt.Print(fmt.Sprintf(bold, string(match.Str[i]))) + } else { + fmt.Print(string(match.Str[i])) + } + } + fmt.Println() + } +} + +func contains(needle int, haystack []int) bool { + for _, i := range haystack { + if needle == i { + return true + } + } + return false +} +``` +If the data you want to match isn't a slice of strings, you can use `FindFromSource` by implementing +the provided `Source` interface. Here's an example: + +```go +package main + +import ( + "fmt" + + "github.com/sahilm/fuzzy" +) + +type employee struct { + name string + age int +} + +type employees []employee + +func (e employees) String(i int) string { + return e[i].name +} + +func (e employees) Len() int { + return len(e) +} + +func main() { + emps := employees{ + { + name: "Alice", + age: 45, + }, + { + name: "Bob", + age: 35, + }, + { + name: "Allie", + age: 35, + }, + } + results := fuzzy.FindFrom("al", emps) + fmt.Println(results) +} +``` + +Check out the [godoc](https://godoc.org/github.com/sahilm/fuzzy) for detailed documentation. + +## Installation + +`go get github.com/sahilm/fuzzy` or use your favorite dependency management tool. + +## Speed + +Here are a few benchmark results on a normal laptop. + +``` +BenchmarkFind/with_unreal_4_(~16K_files)-4 100 12915315 ns/op +BenchmarkFind/with_linux_kernel_(~60K_files)-4 50 30885038 ns/op +``` + +Matching a pattern against ~60K files from the Linux kernel takes about 30ms. + +## Contributing + +Everyone is welcome to contribute. Please send me a pull request or file an issue. I promise +to respond promptly. + +## Credits + +* [@ericpauley](https://github.com/ericpauley) & [@lunixbochs](https://github.com/lunixbochs) contributed Unicode awareness and various performance optimisations. + +* The algorithm is based of the awesome work of [forrestthewoods](https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js). +See [this](https://blog.forrestthewoods.com/reverse-engineering-sublime-text-s-fuzzy-match-4cffeed33fdb#.d05n81yjy) +blog post for details of the algorithm. + +* The artwork is by my lovely wife Sanah. It's based on the Go Gopher. + +* The Go gopher was designed by Renee French (http://reneefrench.blogspot.com/). +The design is licensed under the Creative Commons 3.0 Attributions license. + +## License + +The MIT License (MIT) + +Copyright (c) 2017 Sahil Muthoo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/vendor/github.com/sahilm/fuzzy/fuzzy.go b/vendor/github.com/sahilm/fuzzy/fuzzy.go new file mode 100644 index 0000000..bd66ee6 --- /dev/null +++ b/vendor/github.com/sahilm/fuzzy/fuzzy.go @@ -0,0 +1,235 @@ +/* +Package fuzzy provides fuzzy string matching optimized +for filenames and code symbols in the style of Sublime Text, +VSCode, IntelliJ IDEA et al. +*/ +package fuzzy + +import ( + "sort" + "unicode" + "unicode/utf8" +) + +// Match represents a matched string. +type Match struct { + // The matched string. + Str string + // The index of the matched string in the supplied slice. + Index int + // The indexes of matched characters. Useful for highlighting matches. + MatchedIndexes []int + // Score used to rank matches + Score int +} + +const ( + firstCharMatchBonus = 10 + matchFollowingSeparatorBonus = 20 + camelCaseMatchBonus = 20 + adjacentMatchBonus = 5 + unmatchedLeadingCharPenalty = -5 + maxUnmatchedLeadingCharPenalty = -15 +) + +var separators = []rune("/-_ .\\") + +// Matches is a slice of Match structs +type Matches []Match + +func (a Matches) Len() int { return len(a) } +func (a Matches) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a Matches) Less(i, j int) bool { return a[i].Score >= a[j].Score } + +// Source represents an abstract source of a list of strings. Source must be iterable type such as a slice. +// The source will be iterated over till Len() with String(i) being called for each element where i is the +// index of the element. You can find a working example in the README. +type Source interface { + // The string to be matched at position i. + String(i int) string + // The length of the source. Typically is the length of the slice of things that you want to match. + Len() int +} + +type stringSource []string + +func (ss stringSource) String(i int) string { + return ss[i] +} + +func (ss stringSource) Len() int { return len(ss) } + +/* +Find looks up pattern in data and returns matches +in descending order of match quality. Match quality +is determined by a set of bonus and penalty rules. + +The following types of matches apply a bonus: + +* The first character in the pattern matches the first character in the match string. + +* The matched character is camel cased. + +* The matched character follows a separator such as an underscore character. + +* The matched character is adjacent to a previous match. + +Penalties are applied for every character in the search string that wasn't matched and all leading +characters upto the first match. +*/ +func Find(pattern string, data []string) Matches { + return FindFrom(pattern, stringSource(data)) +} + +/* +FindFrom is an alternative implementation of Find using a Source +instead of a list of strings. +*/ +func FindFrom(pattern string, data Source) Matches { + if len(pattern) == 0 { + return nil + } + runes := []rune(pattern) + var matches Matches + var matchedIndexes []int + for i := 0; i < data.Len(); i++ { + var match Match + match.Str = data.String(i) + match.Index = i + if matchedIndexes != nil { + match.MatchedIndexes = matchedIndexes + } else { + match.MatchedIndexes = make([]int, 0, len(runes)) + } + var score int + patternIndex := 0 + bestScore := -1 + matchedIndex := -1 + currAdjacentMatchBonus := 0 + var last rune + var lastIndex int + nextc, nextSize := utf8.DecodeRuneInString(data.String(i)) + var candidate rune + var candidateSize int + for j := 0; j < len(data.String(i)); j += candidateSize { + candidate, candidateSize = nextc, nextSize + if equalFold(candidate, runes[patternIndex]) { + score = 0 + if j == 0 { + score += firstCharMatchBonus + } + if unicode.IsLower(last) && unicode.IsUpper(candidate) { + score += camelCaseMatchBonus + } + if j != 0 && isSeparator(last) { + score += matchFollowingSeparatorBonus + } + if len(match.MatchedIndexes) > 0 { + lastMatch := match.MatchedIndexes[len(match.MatchedIndexes)-1] + bonus := adjacentCharBonus(lastIndex, lastMatch, currAdjacentMatchBonus) + score += bonus + // adjacent matches are incremental and keep increasing based on previous adjacent matches + // thus we need to maintain the current match bonus + currAdjacentMatchBonus += bonus + } + if score > bestScore { + bestScore = score + matchedIndex = j + } + } + var nextp rune + if patternIndex < len(runes)-1 { + nextp = runes[patternIndex+1] + } + if j+candidateSize < len(data.String(i)) { + if data.String(i)[j+candidateSize] < utf8.RuneSelf { // Fast path for ASCII + nextc, nextSize = rune(data.String(i)[j+candidateSize]), 1 + } else { + nextc, nextSize = utf8.DecodeRuneInString(data.String(i)[j+candidateSize:]) + } + } else { + nextc, nextSize = 0, 0 + } + // We apply the best score when we have the next match coming up or when the search string has ended. + // Tracking when the next match is coming up allows us to exhaustively find the best match and not necessarily + // the first match. + // For example given the pattern "tk" and search string "The Black Knight", exhaustively matching allows us + // to match the second k thus giving this string a higher score. + if equalFold(nextp, nextc) || nextc == 0 { + if matchedIndex > -1 { + if len(match.MatchedIndexes) == 0 { + penalty := matchedIndex * unmatchedLeadingCharPenalty + bestScore += max(penalty, maxUnmatchedLeadingCharPenalty) + } + match.Score += bestScore + match.MatchedIndexes = append(match.MatchedIndexes, matchedIndex) + score = 0 + bestScore = -1 + patternIndex++ + } + } + lastIndex = j + last = candidate + } + // apply penalty for each unmatched character + penalty := len(match.MatchedIndexes) - len(data.String(i)) + match.Score += penalty + if len(match.MatchedIndexes) == len(runes) { + matches = append(matches, match) + matchedIndexes = nil + } else { + matchedIndexes = match.MatchedIndexes[:0] // Recycle match index slice + } + } + sort.Stable(matches) + return matches +} + +// Taken from strings.EqualFold +func equalFold(tr, sr rune) bool { + if tr == sr { + return true + } + if tr < sr { + tr, sr = sr, tr + } + // Fast check for ASCII. + if tr < utf8.RuneSelf { + // ASCII, and sr is upper case. tr must be lower case. + if 'A' <= sr && sr <= 'Z' && tr == sr+'a'-'A' { + return true + } + return false + } + + // General case. SimpleFold(x) returns the next equivalent rune > x + // or wraps around to smaller values. + r := unicode.SimpleFold(sr) + for r != sr && r < tr { + r = unicode.SimpleFold(r) + } + return r == tr +} + +func adjacentCharBonus(i int, lastMatch int, currentBonus int) int { + if lastMatch == i { + return currentBonus*2 + adjacentMatchBonus + } + return 0 +} + +func isSeparator(s rune) bool { + for _, sep := range separators { + if s == sep { + return true + } + } + return false +} + +func max(x int, y int) int { + if x > y { + return x + } + return y +} diff --git a/vendor/modules.txt b/vendor/modules.txt index a3d9226..2d075e0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -6,13 +6,20 @@ github.com/VividCortex/ewma github.com/acarl005/stripansi # github.com/alessio/shellescape v1.4.1 ## explicit; go 1.14 +# github.com/atotto/clipboard v0.1.4 +## explicit +github.com/atotto/clipboard # github.com/aymanbagabas/go-osc52 v1.0.3 ## explicit; go 1.16 github.com/aymanbagabas/go-osc52 # github.com/charmbracelet/bubbles v0.14.0 ## explicit; go 1.13 +github.com/charmbracelet/bubbles/help github.com/charmbracelet/bubbles/key +github.com/charmbracelet/bubbles/list +github.com/charmbracelet/bubbles/paginator github.com/charmbracelet/bubbles/spinner +github.com/charmbracelet/bubbles/textinput # github.com/charmbracelet/bubbletea v0.23.1 ## explicit; go 1.16 github.com/charmbracelet/bubbletea @@ -82,7 +89,7 @@ github.com/mattn/go-runewidth # github.com/mitchellh/mapstructure v1.5.0 ## explicit; go 1.14 github.com/mitchellh/mapstructure -# github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b +# github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 ## explicit; go 1.17 github.com/muesli/ansi github.com/muesli/ansi/compressor @@ -121,6 +128,9 @@ github.com/rivo/uniseg # github.com/rogpeppe/go-internal v1.9.0 ## explicit; go 1.17 github.com/rogpeppe/go-internal/fmtsort +# github.com/sahilm/fuzzy v0.1.0 +## explicit +github.com/sahilm/fuzzy # github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 ## explicit github.com/skratchdot/open-golang/open