package main import ( "context" "fmt" "strings" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "completed/internal/client" "completed/internal/model" ) // screen is which part of the app is currently showing. The model only // ever does one thing at a time, so a single enum (rather than a flag per // feature) keeps Update's branching simple. type screen int const ( screenLogin screen = iota screenLoading screenGames ) // focusField is which of the two login inputs has keyboard focus. type focusField int const ( focusEmail focusField = iota focusPassword ) var ( titleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FAFAFA")). Background(lipgloss.Color("#7D56F4")). Padding(0, 1) errorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF5555")). Bold(true) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555")) focusedInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")) ) // Model is the TUI's whole state. It's a small base: log in, see your // games in a scrollable table, refresh. Adding/editing games is for a // later pass; the point here is a working, tested skeleton the rest of // the TUI can grow from. type Model struct { client *client.Client screen screen err error // Login screen. emailInput textinput.Model passwordInput textinput.Model focus focusField // Loading screen (used for both the initial post-login fetch and a // manual refresh). spinner spinner.Model // Games screen. table table.Model user model.User } // NewModel builds the initial Model, showing the login screen. func NewModel(c *client.Client) Model { email := textinput.New() email.Placeholder = "email" email.Focus() email.CharLimit = 254 password := textinput.New() password.Placeholder = "password" password.EchoMode = textinput.EchoPassword password.CharLimit = 254 s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) return Model{ client: c, screen: screenLogin, emailInput: email, passwordInput: password, spinner: s, table: newGamesTable(nil), } } func newGamesTable(games []model.Game) table.Model { columns := []table.Column{ {Title: "Name", Width: 30}, {Title: "Platform", Width: 16}, {Title: "Score", Width: 7}, {Title: "Released", Width: 12}, {Title: "Finished", Width: 10}, } t := table.New( table.WithColumns(columns), table.WithRows(gamesToRows(games)), table.WithFocused(true), table.WithHeight(15), ) return t } func gamesToRows(games []model.Game) []table.Row { rows := make([]table.Row, len(games)) for i, g := range games { finished := "" if g.Finished != "" { finished = "✓" } rows[i] = table.Row{g.Name, g.PlatformName, fmt.Sprintf("%d", g.Score), g.ReleaseDate, finished} } return rows } // --- Messages --- type loginSucceededMsg struct{ user model.User } type loginFailedMsg struct{ err error } type gamesLoadedMsg struct{ games []model.Game } type gamesLoadFailedMsg struct{ err error } // --- Commands --- func doLogin(c *client.Client, email, password string) tea.Cmd { return func() tea.Msg { resp, err := c.Login(context.Background(), email, password) if err != nil { return loginFailedMsg{err: err} } return loginSucceededMsg{user: resp.User} } } func doListGames(c *client.Client) tea.Cmd { return func() tea.Msg { games, err := c.ListGames(context.Background()) if err != nil { return gamesLoadFailedMsg{err: err} } return gamesLoadedMsg{games: games} } } // --- bubbletea.Model --- func (m Model) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, textinput.Blink) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.updateKey(msg) case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case loginSucceededMsg: m.user = msg.user m.err = nil m.screen = screenLoading return m, doListGames(m.client) case loginFailedMsg: m.err = msg.err return m, nil case gamesLoadedMsg: m.table = newGamesTable(msg.games) m.err = nil m.screen = screenGames return m, nil case gamesLoadFailedMsg: m.err = msg.err m.screen = screenGames return m, nil } return m, nil } func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit } switch m.screen { case screenLogin: return m.updateLoginKey(msg) case screenGames: if msg.String() == "q" { return m, tea.Quit } if msg.String() == "r" { m.screen = screenLoading m.err = nil return m, doListGames(m.client) } var cmd tea.Cmd m.table, cmd = m.table.Update(msg) return m, cmd } return m, nil } func (m Model) updateLoginKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "tab", "shift+tab", "down", "up": if m.focus == focusEmail { m.focus = focusPassword m.emailInput.Blur() m.passwordInput.Focus() } else { m.focus = focusEmail m.passwordInput.Blur() m.emailInput.Focus() } return m, nil case "enter": m.err = nil m.screen = screenLoading return m, doLogin(m.client, m.emailInput.Value(), m.passwordInput.Value()) } var cmd tea.Cmd if m.focus == focusEmail { m.emailInput, cmd = m.emailInput.Update(msg) } else { m.passwordInput, cmd = m.passwordInput.Update(msg) } return m, cmd } func (m Model) View() string { switch m.screen { case screenLogin: return m.viewLogin() case screenLoading: return fmt.Sprintf("%s Loading...\n", m.spinner.View()) case screenGames: return m.viewGames() } return "" } func (m Model) viewLogin() string { var b strings.Builder b.WriteString(titleStyle.Render("Completed") + "\n\n") emailLabel, passwordLabel := "Email", "Password" if m.focus == focusEmail { emailLabel = focusedInputStyle.Render(emailLabel) } else { passwordLabel = focusedInputStyle.Render(passwordLabel) } b.WriteString(emailLabel + "\n" + m.emailInput.View() + "\n\n") b.WriteString(passwordLabel + "\n" + m.passwordInput.View() + "\n\n") if m.err != nil { b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n") } b.WriteString(helpStyle.Render("tab: switch field · enter: log in · ctrl+c: quit")) return b.String() } func (m Model) viewGames() string { var b strings.Builder b.WriteString(titleStyle.Render("Completed") + " " + m.user.Name + "\n\n") if m.err != nil { b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n") } b.WriteString(m.table.View() + "\n\n") b.WriteString(helpStyle.Render("r: refresh · q: quit")) return b.String() }