203 lines
4.8 KiB
Go
203 lines
4.8 KiB
Go
|
|
package adminui
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/charmbracelet/bubbles/textinput"
|
||
|
|
tea "github.com/charmbracelet/bubbletea"
|
||
|
|
"github.com/charmbracelet/lipgloss"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Tab extends tea.Model with an InForm indicator used by the root app to
|
||
|
|
// suppress tab-switch shortcuts while a form is open.
|
||
|
|
type Tab interface {
|
||
|
|
tea.Model
|
||
|
|
InForm() bool
|
||
|
|
}
|
||
|
|
|
||
|
|
var (
|
||
|
|
titleSt = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00BFFF"))
|
||
|
|
activeTS = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#39D353"))
|
||
|
|
dimSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
|
||
|
|
errSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4444"))
|
||
|
|
okSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#39D353"))
|
||
|
|
cursorSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700"))
|
||
|
|
labelSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#7EC8E3"))
|
||
|
|
hdrSt = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#E94560"))
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
helpList = "a:add e:edit d:del j/k:nav r:reload q:quit"
|
||
|
|
helpForm = "tab/shift+tab:nav ←/→:cycle ctrl+s:save esc:cancel"
|
||
|
|
)
|
||
|
|
|
||
|
|
// prefill creates a pre-filled text field (value shown, not just placeholder).
|
||
|
|
func prefill(label, value string) field {
|
||
|
|
f := newTextField(label, "")
|
||
|
|
f.input.SetValue(value)
|
||
|
|
return f
|
||
|
|
}
|
||
|
|
|
||
|
|
// setEnumSel sets the sel index of an enum field to match value.
|
||
|
|
func setEnumSel(f *field, value string) {
|
||
|
|
for i, opt := range f.options {
|
||
|
|
if opt == value {
|
||
|
|
f.sel = i
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// field is a form entry: either a text input or an enum cycle selector.
|
||
|
|
type field struct {
|
||
|
|
label string
|
||
|
|
isEnum bool
|
||
|
|
input textinput.Model
|
||
|
|
options []string
|
||
|
|
sel int
|
||
|
|
hidden bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func newTextField(label, placeholder string) field {
|
||
|
|
ti := textinput.New()
|
||
|
|
ti.Placeholder = placeholder
|
||
|
|
ti.CharLimit = 256
|
||
|
|
return field{label: label, input: ti}
|
||
|
|
}
|
||
|
|
|
||
|
|
func newPasswordField(label string) field {
|
||
|
|
ti := textinput.New()
|
||
|
|
ti.EchoMode = textinput.EchoPassword
|
||
|
|
ti.Placeholder = "password"
|
||
|
|
ti.CharLimit = 128
|
||
|
|
return field{label: label, input: ti}
|
||
|
|
}
|
||
|
|
|
||
|
|
func newEnumField(label string, options []string) field {
|
||
|
|
return field{label: label, isEnum: true, options: options}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *field) value() string {
|
||
|
|
if f.isEnum {
|
||
|
|
return f.options[f.sel]
|
||
|
|
}
|
||
|
|
return f.input.Value()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *field) cycleNext() {
|
||
|
|
if f.isEnum {
|
||
|
|
f.sel = (f.sel + 1) % len(f.options)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *field) cyclePrev() {
|
||
|
|
if f.isEnum {
|
||
|
|
f.sel = (f.sel - 1 + len(f.options)) % len(f.options)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// applyFocus focuses the field at idx and blurs all others.
|
||
|
|
func applyFocus(fields []field, idx int) {
|
||
|
|
for i := range fields {
|
||
|
|
if !fields[i].isEnum {
|
||
|
|
fields[i].input.Blur()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if idx >= 0 && idx < len(fields) && !fields[idx].isEnum {
|
||
|
|
fields[idx].input.Focus()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// nextVisible returns the index of the next non-hidden field after from.
|
||
|
|
func nextVisible(fields []field, from int) int {
|
||
|
|
n := len(fields)
|
||
|
|
for i := 1; i < n; i++ {
|
||
|
|
idx := (from + i) % n
|
||
|
|
if !fields[idx].hidden {
|
||
|
|
return idx
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return from
|
||
|
|
}
|
||
|
|
|
||
|
|
// prevVisible returns the index of the previous non-hidden field before from.
|
||
|
|
func prevVisible(fields []field, from int) int {
|
||
|
|
n := len(fields)
|
||
|
|
for i := 1; i < n; i++ {
|
||
|
|
idx := (from - i + n) % n
|
||
|
|
if !fields[idx].hidden {
|
||
|
|
return idx
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return from
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleFormKey routes a key message to the form: navigates fields or updates
|
||
|
|
// the focused one. Returns (newFocusIdx, cmd).
|
||
|
|
func handleFormKey(fields []field, focusIdx int, msg tea.KeyMsg) (int, tea.Cmd) {
|
||
|
|
switch msg.String() {
|
||
|
|
case "tab", "down":
|
||
|
|
next := nextVisible(fields, focusIdx)
|
||
|
|
applyFocus(fields, next)
|
||
|
|
return next, nil
|
||
|
|
case "shift+tab", "up":
|
||
|
|
prev := prevVisible(fields, focusIdx)
|
||
|
|
applyFocus(fields, prev)
|
||
|
|
return prev, nil
|
||
|
|
}
|
||
|
|
// Route to focused field
|
||
|
|
f := &fields[focusIdx]
|
||
|
|
if f.isEnum {
|
||
|
|
switch msg.String() {
|
||
|
|
case "left", "h":
|
||
|
|
f.cyclePrev()
|
||
|
|
case "right", "l", " ":
|
||
|
|
f.cycleNext()
|
||
|
|
}
|
||
|
|
return focusIdx, nil
|
||
|
|
}
|
||
|
|
var cmd tea.Cmd
|
||
|
|
f.input, cmd = f.input.Update(msg)
|
||
|
|
return focusIdx, cmd
|
||
|
|
}
|
||
|
|
|
||
|
|
// renderForm renders all non-hidden form fields.
|
||
|
|
func renderForm(fields []field, focusIdx int) string {
|
||
|
|
var sb strings.Builder
|
||
|
|
for i := range fields {
|
||
|
|
f := &fields[i]
|
||
|
|
if f.hidden {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
prefix := " "
|
||
|
|
if i == focusIdx {
|
||
|
|
prefix = cursorSt.Render("▸ ")
|
||
|
|
}
|
||
|
|
lbl := labelSt.Render(f.label + ":")
|
||
|
|
if f.isEnum {
|
||
|
|
var parts []string
|
||
|
|
for j, opt := range f.options {
|
||
|
|
if j == f.sel {
|
||
|
|
parts = append(parts, activeTS.Render("["+opt+"]"))
|
||
|
|
} else {
|
||
|
|
parts = append(parts, dimSt.Render(" "+opt+" "))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sb.WriteString(prefix + lbl + " " + strings.Join(parts, "") + "\n")
|
||
|
|
} else {
|
||
|
|
sb.WriteString(prefix + lbl + " " + f.input.View() + "\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return sb.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// renderStatus renders the status line (ok or error style).
|
||
|
|
func renderStatus(msg string, isErr bool) string {
|
||
|
|
if msg == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if isErr {
|
||
|
|
return errSt.Render("✗ " + msg)
|
||
|
|
}
|
||
|
|
return okSt.Render("✓ " + msg)
|
||
|
|
}
|