phase 0.3
This commit is contained in:
commit
4a94bfc4e7
52 changed files with 11653 additions and 0 deletions
203
internal/adminui/shared.go
Normal file
203
internal/adminui/shared.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue