package ui import ( "fmt" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "skyrama-tui/internal/service" "skyrama-tui/internal/style" ) // ─── Messages ───────────────────────────────────────────────────────────────── type LoginSuccessMsg struct { UserID int32 Username string } type LoginErrMsg struct{ Err error } // ─── Model ──────────────────────────────────────────────────────────────────── type LoginModel struct { client *service.GameService inputs []textinput.Model // 0=username, 1=password focus int mode string // "login" | "register" err string width int } func NewLoginModel(client *service.GameService) LoginModel { user := textinput.New() user.Placeholder = "username" user.Focus() user.CharLimit = 64 user.Width = 30 pass := textinput.New() pass.Placeholder = "password" pass.EchoMode = textinput.EchoPassword pass.EchoCharacter = '•' pass.CharLimit = 128 pass.Width = 30 return LoginModel{ client: client, inputs: []textinput.Model{user, pass}, mode: "login", } } func (m LoginModel) Init() tea.Cmd { return textinput.Blink } func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width case tea.KeyMsg: switch msg.String() { case "tab", "down": m.focus = (m.focus + 1) % len(m.inputs) for i := range m.inputs { if i == m.focus { m.inputs[i].Focus() } else { m.inputs[i].Blur() } } case "shift+tab", "up": m.focus = (m.focus - 1 + len(m.inputs)) % len(m.inputs) for i := range m.inputs { if i == m.focus { m.inputs[i].Focus() } else { m.inputs[i].Blur() } } case "ctrl+r": m.mode = "register" m.err = "" case "ctrl+l": m.mode = "login" m.err = "" case "enter": username := strings.TrimSpace(m.inputs[0].Value()) password := m.inputs[1].Value() if username == "" || password == "" { m.err = "username and password required" return m, nil } if m.mode == "login" { return m, m.doLogin(username, password) } return m, m.doRegister(username, password) } } // Forward input events to focused field var cmds []tea.Cmd for i := range m.inputs { var cmd tea.Cmd m.inputs[i], cmd = m.inputs[i].Update(msg) cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) } func (m LoginModel) View() string { banner := style.Title.Render("✈ SKYRAMA ✈") modeLabel := style.PanelHeader.Render("Login") if m.mode == "register" { modeLabel = style.PanelHeader.Render("Register") } form := lipgloss.JoinVertical(lipgloss.Left, style.Muted.Render("Username"), m.inputs[0].View(), "", style.Muted.Render("Password"), m.inputs[1].View(), ) errLine := "" if m.err != "" { errLine = "\n" + style.Error.String() + style.Muted.Render(m.err) } hints := "\n" + style.HelpKey.Render("enter") + style.HelpDesc.Render(" confirm ") + style.HelpKey.Render("ctrl+r") + style.HelpDesc.Render(" register ") + style.HelpKey.Render("ctrl+l") + style.HelpDesc.Render(" login ") + style.HelpKey.Render("tab") + style.HelpDesc.Render(" next field") box := style.Panel.Padding(1, 3).Render( lipgloss.JoinVertical(lipgloss.Left, modeLabel, "", form, errLine, hints), ) content := lipgloss.JoinVertical(lipgloss.Center, banner, "", box) return lipgloss.Place(m.width, 24, lipgloss.Center, lipgloss.Center, content) } // SetErr injects an error string from outside (e.g. from main after a LoginErrMsg). func (m *LoginModel) SetErr(e string) { m.err = e } // ─── Commands ───────────────────────────────────────────────────────────────── func (m LoginModel) doLogin(username, password string) tea.Cmd { return func() tea.Msg { id, err := m.client.Login(username, password) if err != nil { return LoginErrMsg{Err: err} } return LoginSuccessMsg{UserID: id, Username: username} } } func (m LoginModel) doRegister(username, password string) tea.Cmd { return func() tea.Msg { _, err := m.client.Register(username, password) if err != nil { return LoginErrMsg{Err: fmt.Errorf("register: %w", err)} } // Auto-login after register id, err := m.client.Login(username, password) if err != nil { return LoginErrMsg{Err: err} } return LoginSuccessMsg{UserID: id, Username: username} } }