gselect 是一个 基于 Golang + Bubble Tea 的交互式命令行选择器, 以 单选为核心,用于补全 shell 脚本中缺失的“美观、稳定、可控”的交互选择能力。
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

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)
}