skyrama_tui/internal/adminui/shared.go

203 lines
4.8 KiB
Go
Raw Permalink Normal View History

2026-06-04 20:51:20 +03:00
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)
}