commit
40b78ec3cd
5 changed files with 800 additions and 0 deletions
@ -0,0 +1,37 @@ |
|||
# Binaries for programs and plugins |
|||
*.exe |
|||
*.exe~ |
|||
*.dll |
|||
*.so |
|||
*.dylib |
|||
|
|||
# Test binary, built with `go test -c` |
|||
*.test |
|||
|
|||
# Output of the go coverage tool, specifically when used with LiteIDE |
|||
*.out |
|||
|
|||
# Dependency directories (remove the comment below to include it) |
|||
# vendor/ |
|||
|
|||
# Go workspace file |
|||
go.work |
|||
go.work.sum |
|||
|
|||
# IDE |
|||
.idea/ |
|||
.vscode/ |
|||
*.swp |
|||
*.swo |
|||
|
|||
# Compiled binary |
|||
gselect |
|||
|
|||
# OS generated files |
|||
.DS_Store |
|||
.DS_Store? |
|||
._* |
|||
.Spotlight-V100 |
|||
.Trashes |
|||
ehthumbs.db |
|||
Thumbs.db |
|||
@ -0,0 +1,354 @@ |
|||
# gselect —— 交互式 CLI 单选器 |
|||
|
|||
**Product Requirements Document (PRD)** |
|||
|
|||
--- |
|||
|
|||
## 1. 产品概述 |
|||
|
|||
### 1.1 产品定位 |
|||
|
|||
`gselect` 是一个 **基于 Golang + Bubble Tea 的交互式命令行选择器**, |
|||
以 **单选为核心**,用于补全 shell 脚本中缺失的“美观、稳定、可控”的交互选择能力。 |
|||
|
|||
*其定位类似于 **`fzf`**,但目标是:* |
|||
|
|||
* **更简单** |
|||
* **更容易嵌入 shell 脚本** |
|||
* **更容易维护与二次开发** |
|||
|
|||
--- |
|||
|
|||
### 1.2 目标用户 |
|||
|
|||
* 运维工程师 |
|||
* 后端 / 工具链开发者 |
|||
* 编写 shell / bash / zsh / fish 脚本的用户 |
|||
* CLI 工具作者 |
|||
|
|||
--- |
|||
|
|||
### 1.3 非目标 |
|||
|
|||
* 不做复杂 TUI 框架 |
|||
* 不做 REPL |
|||
* 不做表格 / 分屏 / 树状 UI |
|||
* 不支持鼠标操作 |
|||
* 不做插件系统 |
|||
|
|||
--- |
|||
|
|||
## 2. 设计原则(强约束) |
|||
|
|||
1. **Unix 风格** |
|||
|
|||
* stdin 输入 |
|||
* stdout 输出 |
|||
* exit code 表达状态 |
|||
|
|||
2. **单选优先** |
|||
|
|||
* 单选是默认行为 |
|||
* 多选只是可选扩展 |
|||
|
|||
3. **Shell 友好** |
|||
|
|||
* 输出可直接被 `$(...)`、`mapfile`、`read` 使用 |
|||
* 不输出多余日志 |
|||
|
|||
4. **接口极简** |
|||
|
|||
* 参数少 |
|||
* 行为直观 |
|||
* 无配置文件依赖 |
|||
|
|||
--- |
|||
|
|||
## 3. 使用方式总览 |
|||
|
|||
### 3.1 基础用法(默认单选) |
|||
|
|||
```bash |
|||
printf "apple\nbanana\norange\n" | gselect |
|||
``` |
|||
|
|||
用户交互: |
|||
|
|||
* ↑ ↓ 移动 |
|||
* Enter 确认 |
|||
* Esc / Ctrl+C 取消 |
|||
|
|||
stdout: |
|||
|
|||
```text |
|||
banana |
|||
``` |
|||
|
|||
exit code: |
|||
|
|||
* `0`:成功选择 |
|||
* `1`:用户取消 / 无选择 |
|||
|
|||
--- |
|||
|
|||
## 4. 输入规范(stdin) |
|||
|
|||
### 4.1 输入格式 |
|||
|
|||
* 每行一个选项 |
|||
* 空行忽略 |
|||
* 原样显示,不做 trim / escape |
|||
|
|||
```text |
|||
option A |
|||
option B |
|||
option C |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 4.2 可选 Key-Value 输入(扩展) |
|||
|
|||
```text |
|||
Apple|apple |
|||
Banana|banana |
|||
``` |
|||
|
|||
启用参数: |
|||
|
|||
```bash |
|||
gselect --kv |
|||
``` |
|||
|
|||
* UI 显示:`Apple` |
|||
* 输出结果:`apple` |
|||
|
|||
--- |
|||
|
|||
## 5. 输出规范(stdout) |
|||
|
|||
### 5.1 默认输出 |
|||
|
|||
* 输出 **选中项的值** |
|||
* 单行文本 |
|||
* 不包含换行以外的任何字符 |
|||
|
|||
```text |
|||
banana |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 5.2 输出索引 |
|||
|
|||
```bash |
|||
gselect --index |
|||
``` |
|||
|
|||
stdout: |
|||
|
|||
```text |
|||
1 |
|||
``` |
|||
|
|||
(索引从 `0` 开始) |
|||
|
|||
--- |
|||
|
|||
## 6. 退出状态(exit code) |
|||
|
|||
| code | 含义 | |
|||
| ---- | ------------------ | |
|||
| 0 | 用户成功选择 | |
|||
| 1 | 用户取消(Esc / Ctrl+C) | |
|||
| 2 | 输入为空 / 无可选项 | |
|||
| 3 | 参数错误 | |
|||
|
|||
--- |
|||
|
|||
## 7. 命令行参数设计 |
|||
|
|||
### 7.1 参数一览 |
|||
|
|||
| 参数 | 说明 | |
|||
| ----------------- | ----------------- | |
|||
| `--title` | 顶部标题 | |
|||
| `--index` | 输出索引而非值 | |
|||
| `--default` | 默认选中值 | |
|||
| `--default-index` | 默认选中索引 | |
|||
| `--no-search` | 禁用搜索过滤 | |
|||
| `--kv` | 启用 label|value 模式 | |
|||
| `--version` | 显示版本 | |
|||
| `--help` | 帮助信息 | |
|||
|
|||
--- |
|||
|
|||
### 7.2 参数示例 |
|||
|
|||
#### 设置标题 |
|||
|
|||
```bash |
|||
gselect --title "Select a branch" |
|||
``` |
|||
|
|||
#### 默认选中 |
|||
|
|||
```bash |
|||
gselect --default "banana" |
|||
gselect --default-index 1 |
|||
``` |
|||
|
|||
#### 输出索引 |
|||
|
|||
```bash |
|||
gselect --index |
|||
``` |
|||
|
|||
#### 禁用搜索 |
|||
|
|||
```bash |
|||
gselect --no-search |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 8. 交互设计 |
|||
|
|||
### 8.1 键位定义(固定,不可配置) |
|||
|
|||
| 键 | 行为 | |
|||
| ------ | --------- | |
|||
| ↑ / k | 上移 | |
|||
| ↓ / j | 下移 | |
|||
| Enter | 确认 | |
|||
| Esc | 取消 | |
|||
| Ctrl+C | 取消 | |
|||
| 字符输入 | 搜索过滤(如启用) | |
|||
|
|||
--- |
|||
|
|||
### 8.2 搜索行为 |
|||
|
|||
* 默认启用 |
|||
* 子串匹配(不要求 fuzzy) |
|||
* 实时过滤 |
|||
* 可通过 `--no-search` 禁用 |
|||
|
|||
--- |
|||
|
|||
## 9. UI 设计规范 |
|||
|
|||
### 9.1 技术选型 |
|||
|
|||
* `bubbletea` |
|||
* `bubbles/list` |
|||
* `lipgloss` |
|||
|
|||
--- |
|||
|
|||
### 9.2 UI 元素 |
|||
|
|||
* 顶部:标题(可选) |
|||
* 中部:列表 |
|||
* 底部:简要提示(↑↓ Enter Esc) |
|||
|
|||
--- |
|||
|
|||
### 9.3 风格要求 |
|||
|
|||
* 简洁 |
|||
* 对比清晰 |
|||
* 不使用 Emoji |
|||
* 颜色适配暗色终端 |
|||
|
|||
--- |
|||
|
|||
## 10. 内部模型(设计指导) |
|||
|
|||
### 10.1 核心数据结构(建议) |
|||
|
|||
```go |
|||
type Item struct { |
|||
Label string |
|||
Value string |
|||
Index int |
|||
} |
|||
``` |
|||
|
|||
```go |
|||
type Model struct { |
|||
List list.Model |
|||
Selected *Item |
|||
Cancelled bool |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 10.2 退出逻辑 |
|||
|
|||
* Enter → 输出 → `os.Exit(0)` |
|||
* Esc / Ctrl+C → `os.Exit(1)` |
|||
* 无输入 → `os.Exit(2)` |
|||
|
|||
--- |
|||
|
|||
## 11. Shell 使用示例 |
|||
|
|||
### 11.1 基础 |
|||
|
|||
```bash |
|||
choice=$(ls | gselect) || exit 1 |
|||
echo "$choice" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 11.2 Git 分支选择 |
|||
|
|||
```bash |
|||
branch=$(git branch --format='%(refname:short)' | gselect) |
|||
git checkout "$branch" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 11.3 输出索引 |
|||
|
|||
```bash |
|||
idx=$(printf "a\nb\nc\n" | gselect --index) |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 12. 非功能性要求 |
|||
|
|||
* 启动时间 < 50ms |
|||
* 单文件二进制 |
|||
* 无运行时依赖 |
|||
* 支持 Linux / macOS |
|||
* Go ≥ 1.21 |
|||
|
|||
--- |
|||
|
|||
## 13. 未来扩展(不在本期) |
|||
|
|||
* 多选模式 |
|||
* 自定义分隔符 |
|||
* 预览窗 |
|||
* JSON 输入 |
|||
|
|||
--- |
|||
|
|||
## 14. 成功标准(验收) |
|||
|
|||
* 可在 shell 中稳定使用 |
|||
* Ctrl+C 不污染终端 |
|||
* stdout 可直接被脚本消费 |
|||
* 用户无需文档即可理解基本操作 |
|||
|
|||
--- |
|||
|
|||
## 15. 一句话总结 |
|||
|
|||
> **一个“像 fzf 一样好用,但更简单、更可控、更适合脚本”的 CLI 单选器。** |
|||
@ -0,0 +1,29 @@ |
|||
module go-gselect |
|||
|
|||
go 1.25 |
|||
|
|||
require ( |
|||
github.com/charmbracelet/bubbles v0.17.1 |
|||
github.com/charmbracelet/bubbletea v0.25.0 |
|||
github.com/charmbracelet/lipgloss v0.9.1 |
|||
) |
|||
|
|||
require ( |
|||
github.com/atotto/clipboard v0.1.4 // indirect |
|||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect |
|||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect |
|||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect |
|||
github.com/mattn/go-isatty v0.0.18 // indirect |
|||
github.com/mattn/go-localereader v0.0.1 // indirect |
|||
github.com/mattn/go-runewidth v0.0.15 // indirect |
|||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect |
|||
github.com/muesli/cancelreader v0.2.2 // indirect |
|||
github.com/muesli/reflow v0.3.0 // indirect |
|||
github.com/muesli/termenv v0.15.2 // indirect |
|||
github.com/rivo/uniseg v0.2.0 // indirect |
|||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect |
|||
golang.org/x/sync v0.1.0 // indirect |
|||
golang.org/x/sys v0.12.0 // indirect |
|||
golang.org/x/term v0.6.0 // indirect |
|||
golang.org/x/text v0.3.8 // indirect |
|||
) |
|||
@ -0,0 +1,46 @@ |
|||
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/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= |
|||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= |
|||
github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= |
|||
github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= |
|||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= |
|||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= |
|||
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= |
|||
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= |
|||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= |
|||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= |
|||
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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= |
|||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= |
|||
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= |
|||
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
|||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= |
|||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= |
|||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= |
|||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= |
|||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= |
|||
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/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= |
|||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= |
|||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= |
|||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= |
|||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= |
|||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= |
|||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= |
|||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= |
|||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= |
|||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= |
|||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= |
|||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= |
|||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
|||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= |
|||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= |
|||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= |
|||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= |
|||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= |
|||
@ -0,0 +1,334 @@ |
|||
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) |
|||
} |
|||
Loading…
Reference in new issue