From 21684bbf4bcaa9cecd221d43638935e8e67fa355 Mon Sep 17 00:00:00 2001 From: Santo Cariotti Date: Tue, 8 Apr 2025 23:40:40 +0200 Subject: Game list --- pkg/ui/multiplayer/multiplayer.go | 8 +- pkg/ui/views/game.go | 148 ++++++++++++++++++++++++++++++++++ pkg/ui/views/play.go | 164 ++++++++++++++++++++++++++++++-------- pkg/ui/views/views.go | 7 ++ 4 files changed, 291 insertions(+), 36 deletions(-) create mode 100644 pkg/ui/views/game.go diff --git a/pkg/ui/multiplayer/multiplayer.go b/pkg/ui/multiplayer/multiplayer.go index 9d78e81..2f889c8 100644 --- a/pkg/ui/multiplayer/multiplayer.go +++ b/pkg/ui/multiplayer/multiplayer.go @@ -4,15 +4,15 @@ import ( "github.com/boozec/rahanna/internal/network" ) -type PlayNetwork struct { +type GameNetwork struct { server *network.TCPNetwork peer string } -func NewPlayNetwork(localID, localIP string, localPort int) *PlayNetwork { - server := network.NewTCPNetwork(localID, localIP, localPort) +func NewGameNetwork(localID, localIP string, localPort int, callback func()) *GameNetwork { + server := network.NewTCPNetwork(localID, localIP, localPort, callback) peer := "" - return &PlayNetwork{ + return &GameNetwork{ server: server, peer: peer, } diff --git a/pkg/ui/views/game.go b/pkg/ui/views/game.go new file mode 100644 index 0000000..e26e3c9 --- /dev/null +++ b/pkg/ui/views/game.go @@ -0,0 +1,148 @@ +package views + +import ( + "fmt" + + "github.com/boozec/rahanna/internal/api/database" + "github.com/boozec/rahanna/pkg/ui/multiplayer" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Keyboard controls +type gameKeyMap struct { + EnterNewGame key.Binding + StartNewGame key.Binding + GoLogout key.Binding + Quit key.Binding +} + +// Default key bindings for the game model +var defaultGameKeyMap = gameKeyMap{ + GoLogout: key.NewBinding( + key.WithKeys("alt+Q", "alt+q"), + key.WithHelp("Alt+Q", "Logout"), + ), + Quit: key.NewBinding( + key.WithKeys("Q", "q"), + key.WithHelp(" Q", "Quit"), + ), +} + +type GameModel struct { + // UI dimensions + width int + height int + + // UI state + keys playKeyMap + + // Game state + game *database.Game + network *multiplayer.GameNetwork +} + +func NewGameModel(width, height int, game *database.Game, network *multiplayer.GameNetwork) GameModel { + return GameModel{ + width: width, + height: height, + game: game, + network: network, + } +} + +// Init function for GameModel +func (m GameModel) Init() tea.Cmd { + ClearScreen() + return textinput.Blink +} + +func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if exit := handleExit(msg); exit != nil { + return m, exit + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m.handleWindowSize(msg) + case tea.KeyMsg: + return m.handleKeyPress(msg) + } + + return m, nil +} + +// View function for GameModel +func (m GameModel) View() string { + formWidth := getFormWidth(m.width) + // base := lipgloss.NewStyle().Align(lipgloss.Center).Width(m.width) + + content := "abc" + + // Build the main window with error handling + windowContent := m.buildWindowContent(content, formWidth) + + // Create navigation buttons + buttons := m.renderNavigationButtons() + + centeredContent := lipgloss.JoinVertical( + lipgloss.Center, + getLogo(m.width), + windowContent, + lipgloss.NewStyle().MarginTop(2).Render(buttons), + ) + + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + centeredContent, + ) +} + +func (m GameModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + return m, nil +} + +func (m GameModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.GoLogout): + return m, logout(m.width, m.height+1) + + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + } + + return m, nil +} + +func (m GameModel) buildWindowContent(content string, formWidth int) string { + return lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + content, + )), + ) +} + +func (m GameModel) renderNavigationButtons() string { + logoutKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.GoLogout.Help().Key), + m.keys.GoLogout.Help().Desc) + + quitKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.Quit.Help().Key), + m.keys.Quit.Help().Desc) + + return lipgloss.JoinVertical( + lipgloss.Left, + logoutKey, + quitKey, + ) +} diff --git a/pkg/ui/views/play.go b/pkg/ui/views/play.go index 3b9448d..796813c 100644 --- a/pkg/ui/views/play.go +++ b/pkg/ui/views/play.go @@ -7,11 +7,13 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/boozec/rahanna/internal/api/database" "github.com/boozec/rahanna/internal/network" "github.com/boozec/rahanna/pkg/ui/multiplayer" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/paginator" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -19,7 +21,7 @@ import ( const ( chessBoard = ` - A B C D E F G H + A B C D E F G H +---------------+ 8 |♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜| 8 7 |♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟| 7 @@ -30,12 +32,14 @@ const ( 2 |♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙| 2 1 |♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖| 1 +---------------+ - A B C D E F G H + A B C D E F G H ` ) type PlayModelPage int +var start = make(chan int) + const ( LandingPage PlayModelPage = iota InsertCodePage @@ -60,6 +64,8 @@ type playKeyMap struct { StartNewGame key.Binding GoLogout key.Binding Quit key.Binding + NextPage key.Binding + PrevPage key.Binding } // Default key bindings for the play model @@ -80,6 +86,14 @@ var defaultPlayKeyMap = playKeyMap{ key.WithKeys("Q", "q"), key.WithHelp(" Q", "Quit"), ), + NextPage: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→/h", "Next Page"), + ), + PrevPage: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←/l", "Prev Page"), + ), } type PlayModel struct { @@ -93,46 +107,49 @@ type PlayModel struct { namePrompt textinput.Model page PlayModelPage isLoading bool + paginator paginator.Model // Game state playName string - play *database.Game - network *multiplayer.PlayNetwork + game *database.Game + network *multiplayer.GameNetwork + games []database.Game // Store the list of games } // NewPlayModel creates a new play model instance func NewPlayModel(width, height int) PlayModel { - namePrompt := createNamePrompt() + namePrompt := createNamePrompt(width) + p := paginator.New() + p.PerPage = 10 + p.ActiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•") + p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Render("•") return PlayModel{ width: width, height: height, - err: nil, keys: defaultPlayKeyMap, namePrompt: namePrompt, page: LandingPage, - isLoading: false, - playName: "", - play: nil, + paginator: p, } } // Create and configure the name input prompt -func createNamePrompt() textinput.Model { +func createNamePrompt(width int) textinput.Model { namePrompt := textinput.New() namePrompt.Prompt = " " namePrompt.TextStyle = inputStyle namePrompt.Placeholder = "rectangular-lake" namePrompt.Focus() namePrompt.CharLimit = 23 - namePrompt.Width = 23 + namePrompt.Width = getFormWidth(width) return namePrompt } func (m PlayModel) Init() tea.Cmd { ClearScreen() - return textinput.Blink + return tea.Batch(textinput.Blink, m.fetchGames()) } func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -140,6 +157,14 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, exit } + m.paginator.SetTotalPages(len(m.games)) + + select { + case <-start: + return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, m.game, m.network)) + default: + } + switch msg := msg.(type) { case tea.WindowSizeMsg: return m.handleWindowSize(msg) @@ -149,6 +174,8 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handlePlayResponse(msg) case database.Game: return m.handleGameResponse(msg) + case []database.Game: + return m.handleGamesResponse(msg) case error: return m.handleError(msg) } @@ -165,7 +192,7 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m PlayModel) View() string { formWidth := getFormWidth(m.width) - base := lipgloss.NewStyle().Align(lipgloss.Center).Width(m.width) + base := lipgloss.NewStyle().Align(lipgloss.Center).Width(formWidth) content := m.renderPageContent(base) @@ -177,7 +204,7 @@ func (m PlayModel) View() string { centeredContent := lipgloss.JoinVertical( lipgloss.Center, - getLogo(m.width), + getLogo(formWidth), windowContent, lipgloss.NewStyle().MarginTop(2).Render(buttons), ) @@ -211,7 +238,7 @@ func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keys.GoLogout): - return m, m.logout() + return m, logout(m.width, m.height+1) case key.Matches(msg, m.keys.Quit): return m, tea.Quit @@ -223,6 +250,8 @@ func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } + m.paginator, _ = m.paginator.Update(msg) + return m, nil } @@ -233,11 +262,14 @@ func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) { if msg.Error != "" { m.err = fmt.Errorf(msg.Error) if msg.Error == "unauthorized" { - return m, m.logout() + return m, logout(m.width, m.height+1) } } else { m.playName = msg.Ok.Name - m.network = multiplayer.NewPlayNetwork("peer-1", msg.Ok.IP, msg.Ok.Port) + + m.network = multiplayer.NewGameNetwork("peer-1", msg.Ok.IP, msg.Ok.Port, func() { + close(start) + }) } return m, nil @@ -245,7 +277,14 @@ func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) { func (m *PlayModel) handleGameResponse(msg database.Game) (tea.Model, tea.Cmd) { m.isLoading = false - m.play = &msg + m.game = &msg + m.err = nil + return m, nil +} + +func (m *PlayModel) handleGamesResponse(msg []database.Game) (tea.Model, tea.Cmd) { + m.isLoading = false + m.games = msg m.err = nil return m, nil } @@ -256,12 +295,18 @@ func (m *PlayModel) handleError(msg error) (tea.Model, tea.Cmd) { return m, nil } -func (m *PlayModel) renderPageContent(base lipgloss.Style) string { +func (m PlayModel) renderPageContent(base lipgloss.Style) string { switch m.page { case LandingPage: m.namePrompt.Blur() - return chessBoard - + if len(m.games) == 0 { + return base.Render(chessBoard) + } else { + start, end := m.paginator.GetSliceBounds(len(m.games)) + gamesStrings := formatGamesForPage(m.games[start:end], altCodeStyle) + pageInfo := m.paginator.View() + return base.Render(lipgloss.JoinVertical(lipgloss.Center, strings.Join(gamesStrings, "\n"), pageInfo)) + } case InsertCodePage: m.namePrompt.Focus() return m.renderInsertCodeContent(base) @@ -273,6 +318,36 @@ func (m *PlayModel) renderPageContent(base lipgloss.Style) string { return "" } +func formatGamesForPage(games []database.Game, altCodeStyle lipgloss.Style) []string { + var gamesStrings []string + gamesStrings = append(gamesStrings, "Games list") + + longestName := 0 + for _, game := range games { + if len(game.Name) > longestName { + longestName = len(game.Name) + } + } + + for i, game := range games { + indexStr := altCodeStyle.Render(fmt.Sprintf("[%d] ", i)) + nameStr := game.Name + dateStr := game.UpdatedAt.Format("2006-01-02 15:04") + + padding := longestName - len(nameStr) + paddingStr := strings.Repeat(" ", padding+4) + + line := lipgloss.JoinHorizontal(lipgloss.Left, + indexStr, + nameStr, + paddingStr, + lipgloss.NewStyle().Foreground(lipgloss.Color("#d35400")).Render(dateStr), + ) + gamesStrings = append(gamesStrings, line) + } + return gamesStrings +} + func (m PlayModel) renderInsertCodeContent(base lipgloss.Style) string { // When loading, show loading status if m.isLoading { @@ -285,10 +360,10 @@ func (m PlayModel) renderInsertCodeContent(base lipgloss.Style) string { } // When we have a play, show who we're playing against - if m.play != nil { + if m.game != nil { playerName := lipgloss.NewStyle(). Foreground(lipgloss.Color("#e67e22")). - Render(m.play.Player1.Username) + Render(m.game.Player1.Username) statusMsg := fmt.Sprintf("You are playing versus %s", playerName) @@ -304,12 +379,11 @@ func (m PlayModel) renderInsertCodeContent(base lipgloss.Style) string { // Default: show input prompt return base.Render( lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle().Width(23).Render("Insert play code:"), + lipgloss.NewStyle().Render("Insert play code:"), m.namePrompt.View(), lipgloss.NewStyle(). Align(lipgloss.Center). PaddingTop(2). - Width(23). Bold(true). Render(fmt.Sprintf("Press %s to join", lipgloss.NewStyle().Italic(true).Render("Enter"))), @@ -374,10 +448,19 @@ func (m PlayModel) renderNavigationButtons() string { altCodeStyle.Render(m.keys.StartNewGame.Help().Key), m.keys.StartNewGame.Help().Desc) + nextPageKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.NextPage.Help().Key), + m.keys.NextPage.Help().Desc) + + prevPageKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.PrevPage.Help().Key), + m.keys.PrevPage.Help().Desc) + return lipgloss.JoinVertical( lipgloss.Left, enterKey, startKey, + lipgloss.JoinHorizontal(lipgloss.Left, prevPageKey, " | ", nextPageKey), logoutKey, quitKey, ) @@ -500,13 +583,6 @@ func (m PlayModel) enterGame() tea.Cmd { } } -func (m PlayModel) logout() tea.Cmd { - if err := os.Remove(".rahannarc"); err != nil { - return nil - } - return SwitchModelCmd(NewAuthModel(m.width, m.height+1)) -} - // getAuthorizationToken reads the authentication token from the .rahannarc file func getAuthorizationToken() (string, error) { f, err := os.Open(".rahannarc") @@ -541,3 +617,27 @@ func sendAPIRequest(method, url string, payload []byte, authorization string) (* client := &http.Client{} return client.Do(req) } + +func (m *PlayModel) fetchGames() tea.Cmd { + return func() tea.Msg { + var games []database.Game + // Get authorization token + authorization, err := getAuthorizationToken() + if err != nil { + return games + } + + // Send API request + url := os.Getenv("API_BASE") + "/play" + resp, err := sendAPIRequest("GET", url, nil, authorization) + if err != nil { + return games + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&games); err != nil { + return []database.Game{} + } + return games + } +} diff --git a/pkg/ui/views/views.go b/pkg/ui/views/views.go index 9487aef..0e463b7 100644 --- a/pkg/ui/views/views.go +++ b/pkg/ui/views/views.go @@ -142,3 +142,10 @@ func getLogo(width int) string { return logoStyle.Render(logo) } + +func logout(width, height int) tea.Cmd { + if err := os.Remove(".rahannarc"); err != nil { + return nil + } + return SwitchModelCmd(NewAuthModel(width, height+1)) +} -- cgit v1.2.3-18-g5258