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