a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
251 lines
5.6 KiB
Go
251 lines
5.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/zzet/gortex/internal/progress"
|
|
)
|
|
|
|
// CheckItem is one row in a Checklist. Detail is shown right of the label in
|
|
// dim text. Picked starts the row in the checked state; Disabled greys it out
|
|
// and disallows toggling (used for adapters that are detected but already
|
|
// configured, etc.).
|
|
type CheckItem struct {
|
|
Label string
|
|
Detail string
|
|
Picked bool
|
|
Disabled bool
|
|
}
|
|
|
|
// Checklist is a vertical multi-select list with arrow-key navigation,
|
|
// space to toggle, `a` to pick all, `n` to clear, `i` to invert. The widget
|
|
// owns no bubbletea state machinery itself — wizards step it through their
|
|
// own Update; this keeps it embeddable inside a multi-step model without
|
|
// fighting over key events.
|
|
type Checklist struct {
|
|
Items []CheckItem
|
|
Cursor int // index of the focused row
|
|
}
|
|
|
|
// NewChecklist returns a checklist seeded with items. Cursor starts at the
|
|
// first non-disabled row, falling back to 0 if every row is disabled.
|
|
func NewChecklist(items []CheckItem) Checklist {
|
|
c := Checklist{Items: items}
|
|
for i, it := range items {
|
|
if !it.Disabled {
|
|
c.Cursor = i
|
|
break
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Move shifts the cursor by delta, skipping disabled rows. Bounded — moving
|
|
// past either edge clamps. Returns the new cursor index.
|
|
func (c *Checklist) Move(delta int) int {
|
|
if len(c.Items) == 0 {
|
|
return 0
|
|
}
|
|
step := 1
|
|
if delta < 0 {
|
|
step = -1
|
|
}
|
|
idx := c.Cursor
|
|
for n := 0; n < len(c.Items); n++ {
|
|
idx += step
|
|
if idx < 0 {
|
|
idx = 0
|
|
break
|
|
}
|
|
if idx >= len(c.Items) {
|
|
idx = len(c.Items) - 1
|
|
break
|
|
}
|
|
if !c.Items[idx].Disabled {
|
|
c.Cursor = idx
|
|
return c.Cursor
|
|
}
|
|
}
|
|
c.Cursor = idx
|
|
return c.Cursor
|
|
}
|
|
|
|
// ToggleCursor flips the picked state at the cursor (no-op on disabled).
|
|
func (c *Checklist) ToggleCursor() {
|
|
if len(c.Items) == 0 {
|
|
return
|
|
}
|
|
it := &c.Items[c.Cursor]
|
|
if it.Disabled {
|
|
return
|
|
}
|
|
it.Picked = !it.Picked
|
|
}
|
|
|
|
// PickAll sets every non-disabled row to picked.
|
|
func (c *Checklist) PickAll() {
|
|
for i := range c.Items {
|
|
if !c.Items[i].Disabled {
|
|
c.Items[i].Picked = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// PickNone clears every non-disabled row.
|
|
func (c *Checklist) PickNone() {
|
|
for i := range c.Items {
|
|
if !c.Items[i].Disabled {
|
|
c.Items[i].Picked = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// PickInvert flips every non-disabled row.
|
|
func (c *Checklist) PickInvert() {
|
|
for i := range c.Items {
|
|
if !c.Items[i].Disabled {
|
|
c.Items[i].Picked = !c.Items[i].Picked
|
|
}
|
|
}
|
|
}
|
|
|
|
// Picked returns the labels of every checked row in original order.
|
|
func (c Checklist) Picked() []string {
|
|
out := make([]string, 0, len(c.Items))
|
|
for _, it := range c.Items {
|
|
if it.Picked {
|
|
out = append(out, it.Label)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PickedCount returns how many rows are currently checked.
|
|
func (c Checklist) PickedCount() int {
|
|
n := 0
|
|
for _, it := range c.Items {
|
|
if it.Picked {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Render returns the visible portion of the checklist. visibleRows caps how
|
|
// many list rows the caller wants drawn; rows beyond the window are shown as
|
|
// a single "… ↑ N more" / "… ↓ N more" pointer. labelWidth lets callers align
|
|
// detail columns across rows.
|
|
func (c Checklist) Render(visibleRows, labelWidth int) string {
|
|
if visibleRows <= 0 {
|
|
visibleRows = len(c.Items)
|
|
}
|
|
if labelWidth <= 0 {
|
|
labelWidth = c.maxLabelWidth()
|
|
}
|
|
start, end := c.window(visibleRows)
|
|
|
|
var b strings.Builder
|
|
if start > 0 {
|
|
b.WriteString(progress.StyleHint.Render(" … ↑ "))
|
|
b.WriteString(progress.StyleHint.Render(plural(start, "more row")))
|
|
b.WriteString("\n")
|
|
}
|
|
for i := start; i < end; i++ {
|
|
b.WriteString(c.renderRow(i, labelWidth))
|
|
if i < end-1 {
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
if end < len(c.Items) {
|
|
left := len(c.Items) - end
|
|
b.WriteString("\n")
|
|
b.WriteString(progress.StyleHint.Render(" … ↓ "))
|
|
b.WriteString(progress.StyleHint.Render(plural(left, "more row")))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// window returns the [start, end) slice of items to draw given the visibleRows
|
|
// cap, keeping Cursor in view.
|
|
func (c Checklist) window(visibleRows int) (int, int) {
|
|
if len(c.Items) <= visibleRows {
|
|
return 0, len(c.Items)
|
|
}
|
|
half := visibleRows / 2
|
|
start := max(c.Cursor-half, 0)
|
|
end := start + visibleRows
|
|
if end > len(c.Items) {
|
|
end = len(c.Items)
|
|
start = end - visibleRows
|
|
}
|
|
return start, end
|
|
}
|
|
|
|
func (c Checklist) renderRow(i, labelWidth int) string {
|
|
it := c.Items[i]
|
|
mark := "[ ]"
|
|
if it.Picked {
|
|
mark = "[" + progress.StyleAccent.Render("x") + "]"
|
|
}
|
|
label := padRight(it.Label, labelWidth)
|
|
style := progress.StyleVal
|
|
if it.Disabled {
|
|
style = progress.StyleHint
|
|
}
|
|
if i == c.Cursor {
|
|
label = progress.StyleStrong.Render(label)
|
|
} else {
|
|
label = style.Render(label)
|
|
}
|
|
detail := ""
|
|
if it.Detail != "" {
|
|
detail = " " + progress.StyleHint.Render(it.Detail)
|
|
}
|
|
prefix := " "
|
|
if i == c.Cursor {
|
|
prefix = progress.StyleAccent.Render(" ▸")
|
|
}
|
|
return prefix + " " + mark + " " + label + detail
|
|
}
|
|
|
|
func (c Checklist) maxLabelWidth() int {
|
|
n := 0
|
|
for _, it := range c.Items {
|
|
n = max(n, lipgloss.Width(it.Label))
|
|
}
|
|
return n
|
|
}
|
|
|
|
func padRight(s string, n int) string {
|
|
w := lipgloss.Width(s)
|
|
if w >= n {
|
|
return s
|
|
}
|
|
return s + strings.Repeat(" ", n-w)
|
|
}
|
|
|
|
func plural(n int, word string) string {
|
|
if n == 1 {
|
|
return "1 " + word
|
|
}
|
|
return itoa(n) + " " + word + "s"
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
// Tight stdlib-free integer-to-string used by Checklist.Render so we don't
|
|
// drag strconv into a hot rendering path. n is always >= 0 here.
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
for n > 0 {
|
|
i--
|
|
buf[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(buf[i:])
|
|
}
|