Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

195 lines
5.1 KiB
Go

package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/zzet/gortex/internal/progress"
)
// Toggle is one boolean option in an OptionsPanel. Hint is a one-line dim
// explanation shown next to the on/off chip. Detail explains the consequence
// of each pick — surfaced when the toggle is focused.
type Toggle struct {
Label string
Hint string
Detail string
On bool
}
// SelectOption represents one value in a Select widget — e.g. a hook posture
// pick. Detail is shown in dim text under the focused row.
type SelectOption struct {
Value string
Label string
Detail string
}
// Select is a single-pick widget rendered inline as a pillbox row:
// "Hook mode ⟨ deny | enrich | nudge ⟩ — short detail line".
type Select struct {
Label string
Options []SelectOption
Picked int
}
// Move advances the picked index by delta (wraps both ways).
func (s *Select) Move(delta int) {
if len(s.Options) == 0 {
return
}
s.Picked = (s.Picked + delta + len(s.Options)) % len(s.Options)
}
// Value returns the picked option's Value, or "" when empty.
func (s Select) Value() string {
if len(s.Options) == 0 || s.Picked < 0 || s.Picked >= len(s.Options) {
return ""
}
return s.Options[s.Picked].Value
}
// renderRow draws " Hook mode ⟨ deny | enrich | nudge ⟩ — detail".
func (s Select) renderRow(labelWidth int, focused bool) string {
var parts []string
for i, o := range s.Options {
txt := o.Label
switch {
case i == s.Picked && focused:
txt = progress.StyleAccent.Render(txt)
case i == s.Picked:
txt = progress.StyleStrong.Render(txt)
default:
txt = progress.StyleHint.Render(txt)
}
parts = append(parts, txt)
}
pill := progress.StyleSub.Render("⟨ ") +
strings.Join(parts, progress.StyleHint.Render(" | ")) +
progress.StyleSub.Render(" ⟩")
label := padRight(s.Label, labelWidth)
if focused {
label = progress.StyleStrong.Render(label)
} else {
label = progress.StyleVal.Render(label)
}
prefix := " "
if focused {
prefix = progress.StyleAccent.Render(" ▸")
}
detail := ""
if focused && s.Picked >= 0 && s.Picked < len(s.Options) {
d := s.Options[s.Picked].Detail
if d != "" {
detail = " " + progress.StyleHint.Render("— "+d)
}
}
return prefix + " " + label + " " + pill + detail
}
// OptionsPanel renders an aligned column of Toggles + Selects with arrow-key
// navigation between rows and space/enter to flip the focused row. Designed
// to live below a banner and above a key-hint footer inside a wizard step.
type OptionsPanel struct {
Toggles []Toggle
Selects []Select
Cursor int // row index across Toggles ⌢ Selects (toggles first)
}
// total rows = len(Toggles) + len(Selects).
func (p OptionsPanel) total() int { return len(p.Toggles) + len(p.Selects) }
// Move advances the cursor by delta, bounded to [0, total-1].
func (p *OptionsPanel) Move(delta int) {
t := p.total()
if t == 0 {
return
}
p.Cursor += delta
if p.Cursor < 0 {
p.Cursor = 0
}
if p.Cursor >= t {
p.Cursor = t - 1
}
}
// ToggleCursor flips a Toggle at the cursor (no-op when the cursor sits on a
// Select). Returns whether anything changed — wizards use this to gate redraws
// on platforms that lack robust diffing.
func (p *OptionsPanel) ToggleCursor() bool {
if p.Cursor < len(p.Toggles) {
p.Toggles[p.Cursor].On = !p.Toggles[p.Cursor].On
return true
}
return false
}
// CycleCursor advances a Select at the cursor by delta. No-op on a Toggle.
func (p *OptionsPanel) CycleCursor(delta int) bool {
if p.Cursor >= len(p.Toggles) && p.Cursor < p.total() {
p.Selects[p.Cursor-len(p.Toggles)].Move(delta)
return true
}
return false
}
// Render returns the panel as a single block, no trailing newline.
func (p OptionsPanel) Render() string {
labelWidth := p.maxLabelWidth()
var rows []string
for i, t := range p.Toggles {
rows = append(rows, p.renderToggle(i, t, labelWidth))
}
for i, s := range p.Selects {
rowIdx := len(p.Toggles) + i
rows = append(rows, s.renderRow(labelWidth, rowIdx == p.Cursor))
}
return strings.Join(rows, "\n")
}
func (p OptionsPanel) renderToggle(i int, t Toggle, labelWidth int) string {
focused := p.Cursor == i
chipOn := progress.StyleAccent.Render("on")
chipOff := progress.StyleHint.Render("off")
pill := progress.StyleSub.Render("⟨ ")
if t.On {
pill += chipOn + progress.StyleHint.Render(" | ") + progress.StyleHint.Render("off")
} else {
pill += progress.StyleHint.Render("on") + progress.StyleHint.Render(" | ") + chipOff
}
pill += progress.StyleSub.Render(" ⟩")
label := padRight(t.Label, labelWidth)
if focused {
label = progress.StyleStrong.Render(label)
} else {
label = progress.StyleVal.Render(label)
}
prefix := " "
if focused {
prefix = progress.StyleAccent.Render(" ▸")
}
tail := ""
if focused && t.Detail != "" {
tail = " " + progress.StyleHint.Render("— "+t.Detail)
} else if t.Hint != "" {
tail = " " + progress.StyleHint.Render(t.Hint)
}
return prefix + " " + label + " " + pill + tail
}
func (p OptionsPanel) maxLabelWidth() int {
n := 0
for _, t := range p.Toggles {
n = max(n, lipgloss.Width(t.Label))
}
for _, s := range p.Selects {
n = max(n, lipgloss.Width(s.Label))
}
return n
}