Files
lolcathost/internal/tui/form.go
T
lukaszraczylo 22f8f7a008 refactor(tui): move to Bubble Tea, Lip Gloss and Bubbles v2
Switch imports to the charm.land v2 modules. Return tea.View from Model.View,
set alt screen and window title there, use KeyPressMsg and the "space" key
name, and call SetWidth on text inputs.
2026-10-04 10:12:33 +01:00

343 lines
7.1 KiB
Go

// Package tui provides the form component for adding/editing entries.
package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/lolcathost/internal/config"
)
// FormMode represents the form mode.
type FormMode int
const (
FormModeAdd FormMode = iota
FormModeEdit
)
// FormField represents a form field index.
type FormField int
const (
FieldDomain FormField = iota
FieldIP
FieldGroup
FieldCount
)
// Form handles the add/edit entry form.
type Form struct {
mode FormMode
fields []textinput.Model
focus FormField
width int
height int
editAlias string // Original alias when editing
// Group dropdown
groups []string
groupCursor int
groupFocused bool
}
// NewForm creates a new form.
func NewForm() *Form {
fields := make([]textinput.Model, FieldCount)
// Domain field
fields[FieldDomain] = textinput.New()
fields[FieldDomain].Placeholder = "example.com"
fields[FieldDomain].CharLimit = 253
// IP field
fields[FieldIP] = textinput.New()
fields[FieldIP].Placeholder = "127.0.0.1"
fields[FieldIP].CharLimit = 45 // IPv6 max
// Group field (not used as text input, but kept for compatibility)
fields[FieldGroup] = textinput.New()
fields[FieldGroup].Placeholder = "development"
fields[FieldGroup].CharLimit = 63
return &Form{
fields: fields,
focus: FieldDomain,
groups: []string{"default"},
}
}
// SetGroups sets the available groups for the dropdown.
func (f *Form) SetGroups(groups []string) {
if len(groups) == 0 {
f.groups = []string{"default"}
} else {
f.groups = groups
}
// Reset cursor if out of bounds
if f.groupCursor >= len(f.groups) {
f.groupCursor = 0
}
}
// Init initializes the form for adding a new entry.
func (f *Form) Init() {
f.mode = FormModeAdd
f.editAlias = ""
for i := range f.fields {
f.fields[i].Reset()
}
f.fields[FieldIP].SetValue("127.0.0.1")
f.groupCursor = 0
f.groupFocused = false
f.focus = FieldDomain
f.fields[FieldDomain].Focus()
}
// InitEdit initializes the form for editing an existing entry.
func (f *Form) InitEdit(domain, ip, alias, group string) {
f.mode = FormModeEdit
f.editAlias = alias
f.fields[FieldDomain].SetValue(domain)
f.fields[FieldIP].SetValue(ip)
// Find the group in the list
f.groupCursor = 0
for i, g := range f.groups {
if g == group {
f.groupCursor = i
break
}
}
f.groupFocused = false
f.focus = FieldDomain
f.fields[FieldDomain].Focus()
}
// SetSize sets the form dimensions.
func (f *Form) SetSize(width, height int) {
f.width = width
f.height = height
inputWidth := min(50, width-10)
for i := range f.fields {
f.fields[i].SetWidth(inputWidth)
}
}
// Update handles input events.
func (f *Form) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// Handle group dropdown navigation
if f.focus == FieldGroup {
switch msg.String() {
case "tab":
f.nextField()
return nil
case "shift+tab":
f.prevField()
return nil
case "up", "k":
if f.groupCursor > 0 {
f.groupCursor--
}
return nil
case "down", "j":
if f.groupCursor < len(f.groups)-1 {
f.groupCursor++
}
return nil
case "left":
if f.groupCursor > 0 {
f.groupCursor--
}
return nil
case "right":
if f.groupCursor < len(f.groups)-1 {
f.groupCursor++
}
return nil
}
return nil
}
// Handle text input fields
switch msg.String() {
case "tab", "down":
f.nextField()
return nil
case "shift+tab", "up":
f.prevField()
return nil
}
}
// Update the focused text field (only for Domain and IP)
if f.focus != FieldGroup {
var cmd tea.Cmd
f.fields[f.focus], cmd = f.fields[f.focus].Update(msg)
return cmd
}
return nil
}
func (f *Form) nextField() {
if f.focus != FieldGroup {
f.fields[f.focus].Blur()
}
f.focus = (f.focus + 1) % FieldCount
if f.focus != FieldGroup {
f.fields[f.focus].Focus()
}
}
func (f *Form) prevField() {
if f.focus != FieldGroup {
f.fields[f.focus].Blur()
}
f.focus = (f.focus - 1 + FieldCount) % FieldCount
if f.focus != FieldGroup {
f.fields[f.focus].Focus()
}
}
// Values returns the form values (domain, ip, group).
func (f *Form) Values() (domain, ip, group string) {
group = ""
if f.groupCursor < len(f.groups) {
group = f.groups[f.groupCursor]
}
return strings.TrimSpace(f.fields[FieldDomain].Value()),
strings.TrimSpace(f.fields[FieldIP].Value()),
group
}
// EditAlias returns the original alias when editing.
func (f *Form) EditAlias() string {
return f.editAlias
}
// IsEdit returns true if in edit mode.
func (f *Form) IsEdit() bool {
return f.mode == FormModeEdit
}
// Validate validates the form values.
func (f *Form) Validate() string {
domain, ip, group := f.Values()
if domain == "" {
return "Domain is required"
}
if ip == "" {
return "IP address is required"
}
if group == "" {
return "Group is required"
}
// Check if domain is blocked
if config.IsBlockedDomain(domain) {
return fmt.Sprintf("Domain '%s' is blocked (Apple system domain)", domain)
}
return ""
}
// View renders the form.
func (f *Form) View() string {
var sb strings.Builder
title := "Add New Entry"
if f.mode == FormModeEdit {
title = "Edit Entry"
}
sb.WriteString(titleStyle.Render(title))
sb.WriteString("\n\n")
// Domain field
sb.WriteString(inputLabelStyle.Render("Domain:"))
sb.WriteString("\n")
style := inputStyle
if f.focus == FieldDomain {
style = inputFocusStyle
}
sb.WriteString(style.Render(f.fields[FieldDomain].View()))
sb.WriteString("\n\n")
// IP field
sb.WriteString(inputLabelStyle.Render("IP Address:"))
sb.WriteString("\n")
style = inputStyle
if f.focus == FieldIP {
style = inputFocusStyle
}
sb.WriteString(style.Render(f.fields[FieldIP].View()))
sb.WriteString("\n\n")
// Group dropdown
sb.WriteString(inputLabelStyle.Render("Group:"))
sb.WriteString("\n")
sb.WriteString(f.renderGroupDropdown())
sb.WriteString("\n\n")
sb.WriteString("\n")
sb.WriteString(WrapHelpText("Tab/↓ next • Shift+Tab/↑ prev • ←→ select group • Enter save • Esc cancel", f.width-6))
return dialogStyle.Render(sb.String())
}
func (f *Form) renderGroupDropdown() string {
isFocused := f.focus == FieldGroup
// Get current group name
currentGroup := "default"
if f.groupCursor < len(f.groups) {
currentGroup = f.groups[f.groupCursor]
}
// Build the selector content: ◀ group_name ▶
var content string
if isFocused {
// Show arrows when focused
leftArrow := "◀"
rightArrow := "▶"
if f.groupCursor == 0 {
leftArrow = " " // dim or hide left arrow at start
}
if f.groupCursor >= len(f.groups)-1 {
rightArrow = " " // dim or hide right arrow at end
}
content = leftArrow + " " + currentGroup + " " + rightArrow
} else {
content = " " + currentGroup + " "
}
// Show position indicator if multiple groups
if len(f.groups) > 1 {
content += fmt.Sprintf(" (%d/%d)", f.groupCursor+1, len(f.groups))
}
// Apply border style
if isFocused {
return inputFocusStyle.Render(content)
}
return inputStyle.Render(content)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}