You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
334 lines
6.9 KiB
334 lines
6.9 KiB
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/list"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
const version = "0.1.0"
|
|
|
|
type Item struct {
|
|
Label string
|
|
Value string
|
|
Index int
|
|
}
|
|
|
|
func (i Item) FilterValue() string {
|
|
return i.Label
|
|
}
|
|
|
|
type Config struct {
|
|
Title string
|
|
DefaultValue string
|
|
DefaultIndex int
|
|
OutputIndex bool
|
|
NoSearch bool
|
|
KVMode bool
|
|
}
|
|
|
|
type Model struct {
|
|
items []Item
|
|
list list.Model
|
|
selected *Item
|
|
cancelled bool
|
|
config Config
|
|
}
|
|
|
|
type itemDelegate struct {
|
|
selectedStyle lipgloss.Style
|
|
normalStyle lipgloss.Style
|
|
}
|
|
|
|
func (d itemDelegate) Height() int { return 1 }
|
|
func (d itemDelegate) Spacing() int { return 0 }
|
|
func (d itemDelegate) Update(_ tea.Msg, m *list.Model) tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
|
item, ok := listItem.(Item)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
cursor := " "
|
|
style := d.normalStyle
|
|
if index == m.Index() {
|
|
cursor = "> "
|
|
style = d.selectedStyle
|
|
}
|
|
|
|
fmt.Fprint(w, style.Render(cursor+item.Label))
|
|
}
|
|
|
|
func newModel(items []Item, config Config, selectedIndex int) Model {
|
|
listItems := make([]list.Item, 0, len(items))
|
|
for _, item := range items {
|
|
listItems = append(listItems, item)
|
|
}
|
|
|
|
delegate := itemDelegate{
|
|
selectedStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")),
|
|
normalStyle: lipgloss.NewStyle(),
|
|
}
|
|
|
|
l := list.New(listItems, delegate, 0, 0)
|
|
l.SetShowHelp(false)
|
|
l.SetShowStatusBar(false)
|
|
l.SetShowPagination(false)
|
|
l.SetShowTitle(false)
|
|
l.SetFilteringEnabled(!config.NoSearch)
|
|
l.SetShowFilter(!config.NoSearch)
|
|
l.KeyMap.Quit.SetEnabled(false)
|
|
l.KeyMap.ForceQuit.SetEnabled(false)
|
|
|
|
if selectedIndex >= 0 && selectedIndex < len(items) {
|
|
l.Select(selectedIndex)
|
|
}
|
|
|
|
return Model{
|
|
items: items,
|
|
list: l,
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.Type {
|
|
case tea.KeyEnter:
|
|
selected := m.list.SelectedItem()
|
|
if selectedItem, ok := selected.(Item); ok {
|
|
m.selected = &selectedItem
|
|
return m, tea.Quit
|
|
}
|
|
m.cancelled = true
|
|
return m, tea.Quit
|
|
case tea.KeyEsc, tea.KeyCtrlC:
|
|
m.cancelled = true
|
|
return m, tea.Quit
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
heightUsed := 1
|
|
if m.config.Title != "" {
|
|
heightUsed++
|
|
}
|
|
m.list.SetSize(msg.Width, max(0, msg.Height-heightUsed))
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.list, cmd = m.list.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
var b strings.Builder
|
|
|
|
if m.config.Title != "" {
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
|
b.WriteString(titleStyle.Render(m.config.Title))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString(m.list.View())
|
|
|
|
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
|
b.WriteString("\n")
|
|
b.WriteString(helpStyle.Render("↑/↓ (j/k) Enter Esc"))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func readItems(r io.Reader, kvMode bool) ([]Item, error) {
|
|
scanner := bufio.NewScanner(r)
|
|
items := []Item{}
|
|
index := 0
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
label := line
|
|
value := line
|
|
if kvMode {
|
|
if sep := strings.Index(line, "|"); sep >= 0 {
|
|
label = line[:sep]
|
|
value = line[sep+1:]
|
|
}
|
|
}
|
|
|
|
items = append(items, Item{Label: label, Value: value, Index: index})
|
|
index++
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func parseFlags() (Config, bool, bool, bool, bool, error) {
|
|
var cfg Config
|
|
var showHelp bool
|
|
var showVersion bool
|
|
var defaultValueSet bool
|
|
var defaultIndexSet bool
|
|
|
|
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
|
|
fs.StringVar(&cfg.Title, "title", "", "Title shown above the list")
|
|
fs.BoolVar(&cfg.OutputIndex, "index", false, "Output index instead of value")
|
|
fs.BoolVar(&cfg.NoSearch, "no-search", false, "Disable search filtering")
|
|
fs.BoolVar(&cfg.KVMode, "kv", false, "Enable label|value input mode")
|
|
fs.BoolVar(&showVersion, "version", false, "Show version")
|
|
fs.BoolVar(&showHelp, "help", false, "Show help")
|
|
|
|
fs.Func("default", "Default selected value", func(value string) error {
|
|
cfg.DefaultValue = value
|
|
defaultValueSet = true
|
|
return nil
|
|
})
|
|
|
|
fs.Func("default-index", "Default selected index", func(value string) error {
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.DefaultIndex = parsed
|
|
defaultIndexSet = true
|
|
return nil
|
|
})
|
|
|
|
if err := fs.Parse(os.Args[1:]); err != nil {
|
|
return cfg, false, false, false, false, err
|
|
}
|
|
|
|
return cfg, showHelp, showVersion, defaultValueSet, defaultIndexSet, nil
|
|
}
|
|
|
|
func printHelp(w io.Writer) {
|
|
help := `gselect - interactive CLI selector
|
|
|
|
Usage:
|
|
gselect [flags]
|
|
|
|
Flags:
|
|
--title string Title shown above the list
|
|
--index Output index instead of value
|
|
--default string Default selected value
|
|
--default-index int Default selected index
|
|
--no-search Disable search filtering
|
|
--kv Enable label|value input mode
|
|
--version Show version
|
|
--help Show help
|
|
`
|
|
fmt.Fprint(w, help)
|
|
}
|
|
|
|
func clamp(value, minValue, maxValue int) int {
|
|
if value < minValue {
|
|
return minValue
|
|
}
|
|
if value > maxValue {
|
|
return maxValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func main() {
|
|
cfg, showHelp, showVersion, defaultValueSet, defaultIndexSet, err := parseFlags()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(3)
|
|
}
|
|
|
|
if showHelp {
|
|
printHelp(os.Stdout)
|
|
os.Exit(0)
|
|
}
|
|
|
|
if showVersion {
|
|
fmt.Fprintf(os.Stdout, "gselect %s\n", version)
|
|
os.Exit(0)
|
|
}
|
|
|
|
if defaultValueSet && defaultIndexSet {
|
|
fmt.Fprintln(os.Stderr, "--default and --default-index cannot be used together")
|
|
os.Exit(3)
|
|
}
|
|
|
|
items, err := readItems(os.Stdin, cfg.KVMode)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(3)
|
|
}
|
|
if len(items) == 0 {
|
|
os.Exit(2)
|
|
}
|
|
|
|
selectedIndex := 0
|
|
if defaultValueSet {
|
|
for i, item := range items {
|
|
if item.Value == cfg.DefaultValue {
|
|
selectedIndex = i
|
|
break
|
|
}
|
|
}
|
|
} else if defaultIndexSet {
|
|
selectedIndex = clamp(cfg.DefaultIndex, 0, len(items)-1)
|
|
}
|
|
|
|
model := newModel(items, cfg, selectedIndex)
|
|
tty, ttyErr := os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
|
if ttyErr != nil {
|
|
fmt.Fprintln(os.Stderr, "failed to open /dev/tty for input/output")
|
|
os.Exit(3)
|
|
}
|
|
defer tty.Close()
|
|
|
|
program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithInput(tty), tea.WithOutput(tty))
|
|
finalModel, runErr := program.Run()
|
|
if runErr != nil {
|
|
if runErr == tea.ErrProgramKilled {
|
|
os.Exit(1)
|
|
}
|
|
fmt.Fprintln(os.Stderr, runErr)
|
|
os.Exit(1)
|
|
}
|
|
|
|
result := finalModel.(Model)
|
|
if result.cancelled || result.selected == nil {
|
|
os.Exit(1)
|
|
}
|
|
|
|
if cfg.OutputIndex {
|
|
fmt.Fprintln(os.Stdout, result.selected.Index)
|
|
os.Exit(0)
|
|
}
|
|
|
|
fmt.Fprintln(os.Stdout, result.selected.Value)
|
|
}
|
|
|