diff options
author | Santo Cariotti <santo@dcariotti.me> | 2025-04-17 10:52:47 +0200 |
---|---|---|
committer | Santo Cariotti <santo@dcariotti.me> | 2025-04-17 10:52:47 +0200 |
commit | 313c96613153d92e4964bef4d2469b09a9505597 (patch) | |
tree | 0b7e8e58f826a2ef4821145eb27b045cf760849c /pkg/ui | |
parent | 39a594829ebddc0bc06b92465241439f81fca205 (diff) |
Split views on subfiles
Diffstat (limited to 'pkg/ui')
-rw-r--r-- | pkg/ui/views/game.go | 176 | ||||
-rw-r--r-- | pkg/ui/views/game_api.go | 59 | ||||
-rw-r--r-- | pkg/ui/views/game_keymap.go | 54 | ||||
-rw-r--r-- | pkg/ui/views/game_moves.go | 66 | ||||
-rw-r--r-- | pkg/ui/views/game_util.go | 28 | ||||
-rw-r--r-- | pkg/ui/views/play.go | 483 | ||||
-rw-r--r-- | pkg/ui/views/play_api.go | 221 | ||||
-rw-r--r-- | pkg/ui/views/play_content.go | 87 | ||||
-rw-r--r-- | pkg/ui/views/play_keymap.go | 137 | ||||
-rw-r--r-- | pkg/ui/views/play_util.go | 74 |
10 files changed, 726 insertions, 659 deletions
diff --git a/pkg/ui/views/game.go b/pkg/ui/views/game.go index f97013b..57f3f8e 100644 --- a/pkg/ui/views/game.go +++ b/pkg/ui/views/game.go @@ -1,14 +1,11 @@ package views import ( - "encoding/json" "fmt" - "os" "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/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -16,38 +13,6 @@ import ( "github.com/notnil/chess" ) -// gameKeyMap defines the key bindings for the game view. -type gameKeyMap struct { - GoLogout key.Binding - Quit key.Binding -} - -// defaultGameKeyMap provides the default key bindings for the game view. -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"), - ), -} - -// ChessMoveMsg is a message containing a received chess move. -type ChessMoveMsg string - -// UpdateMovesListMsg is a message to update the moves list -type UpdateMovesListMsg struct{} - -type item struct { - title string -} - -func (i item) Title() string { return i.title } -func (i item) Description() string { return "" } -func (i item) FilterValue() string { return i.title } - // GameModel represents the state of the game view. type GameModel struct { // UI dimensions @@ -104,12 +69,6 @@ func (m GameModel) Init() tea.Cmd { return tea.Batch(textinput.Blink, m.getGame(), m.getMoves(), m.updateMovesListCmd()) } -func (m *GameModel) updateMovesListCmd() tea.Cmd { - return func() tea.Msg { - return UpdateMovesListMsg{} - } -} - // Update handles incoming messages and updates the GameModel. func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if exit := handleExit(msg); exit != nil { @@ -157,60 +116,6 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m GameModel) handleWindowSizeMsg(msg tea.WindowSizeMsg) (GameModel, tea.Cmd) { - m.width = msg.Width - m.height = msg.Height - listWidth := m.width / 4 - m.movesList.SetSize(listWidth, m.height/2) - return m, m.updateMovesListCmd() -} - -func (m GameModel) handleUpdateMovesListMsg() GameModel { - if m.isMyTurn() && m.game != nil { - var items []list.Item - for _, move := range m.chessGame.ValidMoves() { - items = append(items, item{title: move.String()}) - } - m.movesList.SetItems(items) - m.movesList.Title = "Choose a move" - m.movesList.Select(0) - m.movesList.SetShowFilter(true) - m.movesList.SetFilteringEnabled(true) - m.movesList.ResetFilter() - } - return m -} - -func (m GameModel) handleKeyMsg(msg tea.KeyMsg) (GameModel, 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) handleChessMoveMsg(msg ChessMoveMsg) (GameModel, tea.Cmd) { - m.turn++ - err := m.chessGame.MoveStr(string(msg)) - if err != nil { - fmt.Println("Error applying move:", err) - } - return m, tea.Batch(m.getMoves(), m.updateMovesListCmd()) -} - -func (m GameModel) handleDatabaseGameMsg(msg database.Game) GameModel { - m.game = &msg - if m.peer == "peer-2" { - m.network.Peer = msg.IP2 - } else { - m.network.Peer = msg.IP1 - } - return m -} - // View renders the GameModel. func (m GameModel) View() string { formWidth := getFormWidth(m.width) @@ -271,84 +176,3 @@ func (m GameModel) View() string { centeredContent, ) } - -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, - ) -} - -func (m *GameModel) getGame() tea.Cmd { - return func() tea.Msg { - var game database.Game - - // Get authorization token - authorization, err := getAuthorizationToken() - if err != nil { - return nil - } - - // Send API request - url := fmt.Sprintf("%s/play/%d", os.Getenv("API_BASE"), m.currentGameID) - resp, err := sendAPIRequest("GET", url, nil, authorization) - if err != nil { - return nil - } - defer resp.Body.Close() - - if err := json.NewDecoder(resp.Body).Decode(&game); err != nil { - return nil - } - - // Establish peer connection - if m.peer == "peer-2" { - if game.IP2 != "" { - remote := game.IP2 - go m.network.Server.AddPeer("peer-2", remote) - } - } else { - if game.IP1 != "" { - remote := game.IP1 - go m.network.Server.AddPeer("peer-1", remote) - } - } - - return game - } -} - -func (m *GameModel) getMoves() tea.Cmd { - m.network.Server.OnReceiveFn = func(msg network.Message) { - moveStr := string(msg.Payload) - m.incomingMoves <- moveStr - } - - return func() tea.Msg { - move := <-m.incomingMoves - return ChessMoveMsg(move) - } -} - -func (m GameModel) isMyTurn() bool { - return m.turn%2 == 0 && m.peer == "peer-2" || m.turn%2 == 1 && m.peer == "peer-1" -} diff --git a/pkg/ui/views/game_api.go b/pkg/ui/views/game_api.go new file mode 100644 index 0000000..5d4edf9 --- /dev/null +++ b/pkg/ui/views/game_api.go @@ -0,0 +1,59 @@ +package views + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/boozec/rahanna/internal/api/database" + tea "github.com/charmbracelet/bubbletea" +) + +func (m GameModel) handleDatabaseGameMsg(msg database.Game) GameModel { + m.game = &msg + if m.peer == "peer-2" { + m.network.Peer = msg.IP2 + } else { + m.network.Peer = msg.IP1 + } + return m +} + +func (m *GameModel) getGame() tea.Cmd { + return func() tea.Msg { + var game database.Game + + // Get authorization token + authorization, err := getAuthorizationToken() + if err != nil { + return nil + } + + // Send API request + url := fmt.Sprintf("%s/play/%d", os.Getenv("API_BASE"), m.currentGameID) + resp, err := sendAPIRequest("GET", url, nil, authorization) + if err != nil { + return nil + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&game); err != nil { + return nil + } + + // Establish peer connection + if m.peer == "peer-2" { + if game.IP2 != "" { + remote := game.IP2 + go m.network.Server.AddPeer("peer-2", remote) + } + } else { + if game.IP1 != "" { + remote := game.IP1 + go m.network.Server.AddPeer("peer-1", remote) + } + } + + return game + } +} diff --git a/pkg/ui/views/game_keymap.go b/pkg/ui/views/game_keymap.go new file mode 100644 index 0000000..dff75a9 --- /dev/null +++ b/pkg/ui/views/game_keymap.go @@ -0,0 +1,54 @@ +package views + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// gameKeyMap defines the key bindings for the game view. +type gameKeyMap struct { + GoLogout key.Binding + Quit key.Binding +} + +// defaultGameKeyMap provides the default key bindings for the game view. +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"), + ), +} + +func (m GameModel) handleKeyMsg(msg tea.KeyMsg) (GameModel, 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) 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/game_moves.go b/pkg/ui/views/game_moves.go new file mode 100644 index 0000000..ee2c2db --- /dev/null +++ b/pkg/ui/views/game_moves.go @@ -0,0 +1,66 @@ +package views + +import ( + "fmt" + + "github.com/boozec/rahanna/internal/network" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +// UpdateMovesListMsg is a message to update the moves list +type UpdateMovesListMsg struct{} + +// ChessMoveMsg is a message containing a received chess move. +type ChessMoveMsg string + +type item struct { + title string +} + +func (i item) Title() string { return i.title } +func (i item) Description() string { return "" } +func (i item) FilterValue() string { return i.title } + +func (m *GameModel) getMoves() tea.Cmd { + m.network.Server.OnReceiveFn = func(msg network.Message) { + moveStr := string(msg.Payload) + m.incomingMoves <- moveStr + } + + return func() tea.Msg { + move := <-m.incomingMoves + return ChessMoveMsg(move) + } +} + +func (m *GameModel) updateMovesListCmd() tea.Cmd { + return func() tea.Msg { + return UpdateMovesListMsg{} + } +} + +func (m GameModel) handleUpdateMovesListMsg() GameModel { + if m.isMyTurn() && m.game != nil { + var items []list.Item + for _, move := range m.chessGame.ValidMoves() { + items = append(items, item{title: move.String()}) + } + m.movesList.SetItems(items) + m.movesList.Title = "Choose a move" + m.movesList.Select(0) + m.movesList.SetShowFilter(true) + m.movesList.SetFilteringEnabled(true) + m.movesList.ResetFilter() + } + return m +} + +func (m GameModel) handleChessMoveMsg(msg ChessMoveMsg) (GameModel, tea.Cmd) { + m.turn++ + err := m.chessGame.MoveStr(string(msg)) + if err != nil { + fmt.Println("Error applying move:", err) + } + return m, tea.Batch(m.getMoves(), m.updateMovesListCmd()) +} diff --git a/pkg/ui/views/game_util.go b/pkg/ui/views/game_util.go new file mode 100644 index 0000000..8e82fcf --- /dev/null +++ b/pkg/ui/views/game_util.go @@ -0,0 +1,28 @@ +package views + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m GameModel) handleWindowSizeMsg(msg tea.WindowSizeMsg) (GameModel, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + listWidth := m.width / 4 + m.movesList.SetSize(listWidth, m.height/2) + return m, m.updateMovesListCmd() +} + +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) isMyTurn() bool { + return m.turn%2 == 0 && m.peer == "peer-2" || m.turn%2 == 1 && m.peer == "peer-1" +} diff --git a/pkg/ui/views/play.go b/pkg/ui/views/play.go index 3997a88..6ebebb3 100644 --- a/pkg/ui/views/play.go +++ b/pkg/ui/views/play.go @@ -1,18 +1,11 @@ package views import ( - "encoding/json" "fmt" - "net/http" - "os" - "strconv" "strings" "github.com/boozec/rahanna/internal/api/database" - "github.com/boozec/rahanna/internal/logger" - "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" @@ -36,67 +29,6 @@ A B C D E F G H ` ) -type PlayModelPage int - -const ( - LandingPage PlayModelPage = iota - InsertCodePage - StartGamePage -) - -type responseOk struct { - Name string `json:"name"` - GameID int `json:"id"` - IP string `json:"ip"` - Port int `json:"int"` -} - -// API response types -type playResponse struct { - Ok responseOk - Error string `json:"error"` -} - -// Keyboard controls -type playKeyMap struct { - EnterNewGame key.Binding - StartNewGame key.Binding - GoLogout key.Binding - Quit key.Binding - NextPage key.Binding - PrevPage key.Binding -} - -// Default key bindings for the play model -var defaultPlayKeyMap = playKeyMap{ - EnterNewGame: key.NewBinding( - key.WithKeys("alt+E", "alt+e"), - key.WithHelp("Alt+E", "Enter a play using code"), - ), - StartNewGame: key.NewBinding( - key.WithKeys("alt+s", "alt+S"), - key.WithHelp("Alt+S", "Start a new play"), - ), - 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"), - ), - NextPage: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("→/h", "Next Page"), - ), - PrevPage: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("←/l", "Prev Page"), - ), -} - -type StartGameMsg struct{} - type PlayModel struct { // UI dimensions width int @@ -136,19 +68,6 @@ func NewPlayModel(width, height int) PlayModel { } } -// Create and configure the name input prompt -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 = getFormWidth(width) - - return namePrompt -} - func (m PlayModel) Init() tea.Cmd { ClearScreen() return tea.Batch(textinput.Blink, m.fetchGames()) @@ -206,405 +125,3 @@ func (m PlayModel) View() string { centeredContent, ) } - -func (m PlayModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { - m.width = msg.Width - m.height = msg.Height - return m, nil -} - -func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch { - case key.Matches(msg, m.keys.EnterNewGame): - m.page = InsertCodePage - m.namePrompt, cmd = m.namePrompt.Update("suca") - return m, cmd - - case key.Matches(msg, m.keys.StartNewGame): - m.page = StartGamePage - if !m.isLoading { - m.isLoading = true - return m, m.newGameCallback() - } - - 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 - - case msg.Type == tea.KeyEnter: - if m.page == InsertCodePage && !m.isLoading { - m.isLoading = true - return m, m.enterGame() - } - } - - m.paginator, _ = m.paginator.Update(msg) - - if m.page == InsertCodePage { - m.namePrompt, cmd = m.namePrompt.Update(msg) - return m, cmd - } - - return m, nil -} - -func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) { - m.isLoading = false - m.err = nil - - if msg.Error != "" { - m.err = fmt.Errorf("%s", msg.Error) - if msg.Error == "unauthorized" { - return m, logout(m.width, m.height+1) - } - } else { - m.playName = msg.Ok.Name - m.currentGameId = msg.Ok.GameID - logger, _ := logger.GetLogger() - - callbackCompleted := make(chan bool) - m.network = multiplayer.NewGameNetwork("peer-1", fmt.Sprintf("%s:%d", msg.Ok.IP, msg.Ok.Port), func() error { - close(callbackCompleted) - return nil - }, logger) - - return m, func() tea.Msg { - <-callbackCompleted - return StartGameMsg{} - } - } - - return m, nil -} - -func (m *PlayModel) handleGameResponse(msg database.Game) (tea.Model, tea.Cmd) { - m.isLoading = false - m.game = &msg - m.err = nil - ip := strings.Split(m.game.IP2, ":") - if len(ip) == 2 { - localIP := ip[0] - localPort, _ := strconv.ParseInt(ip[1], 10, 32) - - logger, _ := logger.GetLogger() - network := multiplayer.NewGameNetwork("peer-2", fmt.Sprintf("%s:%d", localIP, localPort), func() error { - return nil - }, logger) - - return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, "peer-1", m.game.ID, network)) - } - return m, nil -} - -func (m *PlayModel) handleGamesResponse(msg []database.Game) (tea.Model, tea.Cmd) { - m.isLoading = false - m.games = msg - m.err = nil - m.paginator.SetTotalPages(len(m.games)) - return m, nil -} - -func (m *PlayModel) handleError(msg error) (tea.Model, tea.Cmd) { - m.isLoading = false - m.err = msg - return m, nil -} - -func (m PlayModel) renderPageContent(base lipgloss.Style) string { - switch m.page { - case LandingPage: - m.namePrompt.Blur() - 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: - return m.renderInsertCodeContent(base) - - case StartGamePage: - return m.renderStartGameContent(base) - } - - 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 { - return base.Render( - lipgloss.NewStyle(). - Align(lipgloss.Center). - Bold(true). - Render("Loading..."), - ) - } - - // Default: show input prompt - return base.Render( - lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle().Render("Insert play code:"), - m.namePrompt.View(), - lipgloss.NewStyle(). - Align(lipgloss.Center). - PaddingTop(2). - Bold(true). - Render(fmt.Sprintf("Press %s to join", - lipgloss.NewStyle().Italic(true).Render("Enter"))), - ), - ) -} - -func (m PlayModel) renderStartGameContent(base lipgloss.Style) string { - var statusMsg string - - if m.isLoading { - statusMsg = "Loading..." - } else if m.playName != "" { - gameCode := lipgloss.NewStyle(). - Italic(true). - Foreground(lipgloss.Color("#F39C12")). - Render(m.playName) - - statusMsg = fmt.Sprintf("Share `%s` to your friend", gameCode) - } - - return base.Render(statusMsg) -} - -func (m PlayModel) buildWindowContent(content string, formWidth int) string { - if m.err != nil { - formError := fmt.Sprintf("Error: %v", m.err.Error()) - return lipgloss.JoinVertical( - lipgloss.Center, - windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( - lipgloss.Center, - errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), - content, - )), - ) - } - - return lipgloss.JoinVertical( - lipgloss.Center, - windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( - lipgloss.Center, - content, - )), - ) -} - -func (m PlayModel) 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) - - if m.page == LandingPage { - enterKey := fmt.Sprintf("%s %s", - altCodeStyle.Render(m.keys.EnterNewGame.Help().Key), - m.keys.EnterNewGame.Help().Desc) - - startKey := fmt.Sprintf("%s %s", - 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, - ) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - logoutKey, - quitKey, - ) -} - -func (m *PlayModel) newGameCallback() tea.Cmd { - return func() tea.Msg { - // Get authorization token - authorization, err := getAuthorizationToken() - if err != nil { - return playResponse{Error: err.Error()} - } - - // Set up network connection - port, err := network.GetRandomAvailablePort() - if err != nil { - return playResponse{Error: err.Error()} - } - - ip := network.GetOutboundIP().String() - // FIXME: ip - ip = "0.0.0.0" - - // Prepare request payload - payload, err := json.Marshal(map[string]string{ - "ip": fmt.Sprintf("%s:%d", ip, port), - }) - if err != nil { - return playResponse{Error: err.Error()} - } - - // Send API request - url := os.Getenv("API_BASE") + "/play" - resp, err := sendAPIRequest("POST", url, payload, authorization) - if err != nil { - return playResponse{Error: err.Error()} - } - defer resp.Body.Close() - - // Handle response - if resp.StatusCode != http.StatusOK { - var response playResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} - } - return playResponse{Error: response.Error} - } - - // Decode successful response - var response struct { - Name string `json:"name"` - ID int `json:"id"` - Error string `json:"error"` - } - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} - } - - return playResponse{Ok: responseOk{Name: response.Name, GameID: response.ID, IP: ip, Port: port}} - } -} - -func (m PlayModel) enterGame() tea.Cmd { - return func() tea.Msg { - // Get authorization token - authorization, err := getAuthorizationToken() - if err != nil { - return playResponse{Error: err.Error()} - } - - // Set up network connection - port, err := network.GetRandomAvailablePort() - if err != nil { - return playResponse{Error: err.Error()} - } - - ip := network.GetOutboundIP().String() - // FIXME: ip - ip = "0.0.0.0" - - // Prepare request payload - payload, err := json.Marshal(map[string]string{ - "ip": fmt.Sprintf("%s:%d", ip, port), - "name": m.namePrompt.Value(), - }) - if err != nil { - return playResponse{Error: err.Error()} - } - - // Send API request - url := os.Getenv("API_BASE") + "/enter-game" - resp, err := sendAPIRequest("POST", url, payload, authorization) - if err != nil { - return playResponse{Error: err.Error()} - } - defer resp.Body.Close() - - // Handle response - if resp.StatusCode != http.StatusOK { - var response playResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} - } - return playResponse{Error: response.Error} - } - - // Decode successful response - var response database.Game - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} - } - - return response - } -} - -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/play_api.go b/pkg/ui/views/play_api.go new file mode 100644 index 0000000..1fb02e5 --- /dev/null +++ b/pkg/ui/views/play_api.go @@ -0,0 +1,221 @@ +package views + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/boozec/rahanna/internal/api/database" + "github.com/boozec/rahanna/internal/logger" + "github.com/boozec/rahanna/internal/network" + "github.com/boozec/rahanna/pkg/ui/multiplayer" + tea "github.com/charmbracelet/bubbletea" +) + +type responseOk struct { + Name string `json:"name"` + GameID int `json:"id"` + IP string `json:"ip"` + Port int `json:"int"` +} + +// API response types +type playResponse struct { + Ok responseOk + Error string `json:"error"` +} + +type StartGameMsg struct{} + +func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) { + m.isLoading = false + m.err = nil + + if msg.Error != "" { + m.err = fmt.Errorf("%s", msg.Error) + if msg.Error == "unauthorized" { + return m, logout(m.width, m.height+1) + } + } else { + m.playName = msg.Ok.Name + m.currentGameId = msg.Ok.GameID + logger, _ := logger.GetLogger() + + callbackCompleted := make(chan bool) + m.network = multiplayer.NewGameNetwork("peer-1", fmt.Sprintf("%s:%d", msg.Ok.IP, msg.Ok.Port), func() error { + close(callbackCompleted) + return nil + }, logger) + + return m, func() tea.Msg { + <-callbackCompleted + return StartGameMsg{} + } + } + + return m, nil +} +func (m *PlayModel) handleGameResponse(msg database.Game) (tea.Model, tea.Cmd) { + m.isLoading = false + m.game = &msg + m.err = nil + ip := strings.Split(m.game.IP2, ":") + if len(ip) == 2 { + localIP := ip[0] + localPort, _ := strconv.ParseInt(ip[1], 10, 32) + + logger, _ := logger.GetLogger() + network := multiplayer.NewGameNetwork("peer-2", fmt.Sprintf("%s:%d", localIP, localPort), func() error { + return nil + }, logger) + + return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, "peer-1", m.game.ID, network)) + } + return m, nil +} + +func (m *PlayModel) handleGamesResponse(msg []database.Game) (tea.Model, tea.Cmd) { + m.isLoading = false + m.games = msg + m.err = nil + m.paginator.SetTotalPages(len(m.games)) + return m, nil +} + +func (m *PlayModel) newGameCallback() tea.Cmd { + return func() tea.Msg { + // Get authorization token + authorization, err := getAuthorizationToken() + if err != nil { + return playResponse{Error: err.Error()} + } + + // Set up network connection + port, err := network.GetRandomAvailablePort() + if err != nil { + return playResponse{Error: err.Error()} + } + + ip := network.GetOutboundIP().String() + // FIXME: ip + ip = "0.0.0.0" + + // Prepare request payload + payload, err := json.Marshal(map[string]string{ + "ip": fmt.Sprintf("%s:%d", ip, port), + }) + if err != nil { + return playResponse{Error: err.Error()} + } + + // Send API request + url := os.Getenv("API_BASE") + "/play" + resp, err := sendAPIRequest("POST", url, payload, authorization) + if err != nil { + return playResponse{Error: err.Error()} + } + defer resp.Body.Close() + + // Handle response + if resp.StatusCode != http.StatusOK { + var response playResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return playResponse{Error: response.Error} + } + + // Decode successful response + var response struct { + Name string `json:"name"` + ID int `json:"id"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return playResponse{Ok: responseOk{Name: response.Name, GameID: response.ID, IP: ip, Port: port}} + } +} + +func (m PlayModel) enterGame() tea.Cmd { + return func() tea.Msg { + // Get authorization token + authorization, err := getAuthorizationToken() + if err != nil { + return playResponse{Error: err.Error()} + } + + // Set up network connection + port, err := network.GetRandomAvailablePort() + if err != nil { + return playResponse{Error: err.Error()} + } + + ip := network.GetOutboundIP().String() + // FIXME: ip + ip = "0.0.0.0" + + // Prepare request payload + payload, err := json.Marshal(map[string]string{ + "ip": fmt.Sprintf("%s:%d", ip, port), + "name": m.namePrompt.Value(), + }) + if err != nil { + return playResponse{Error: err.Error()} + } + + // Send API request + url := os.Getenv("API_BASE") + "/enter-game" + resp, err := sendAPIRequest("POST", url, payload, authorization) + if err != nil { + return playResponse{Error: err.Error()} + } + defer resp.Body.Close() + + // Handle response + if resp.StatusCode != http.StatusOK { + var response playResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return playResponse{Error: response.Error} + } + + // Decode successful response + var response database.Game + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +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/play_content.go b/pkg/ui/views/play_content.go new file mode 100644 index 0000000..a128800 --- /dev/null +++ b/pkg/ui/views/play_content.go @@ -0,0 +1,87 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/lipgloss" +) + +// Create and configure the name input prompt +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 = getFormWidth(width) + + return namePrompt +} + +func (m PlayModel) renderPageContent(base lipgloss.Style) string { + switch m.page { + case LandingPage: + m.namePrompt.Blur() + 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: + return m.renderInsertCodeContent(base) + + case StartGamePage: + return m.renderStartGameContent(base) + } + + return "" +} + +func (m PlayModel) renderInsertCodeContent(base lipgloss.Style) string { + // When loading, show loading status + if m.isLoading { + return base.Render( + lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Render("Loading..."), + ) + } + + // Default: show input prompt + return base.Render( + lipgloss.JoinVertical(lipgloss.Left, + lipgloss.NewStyle().Render("Insert play code:"), + m.namePrompt.View(), + lipgloss.NewStyle(). + Align(lipgloss.Center). + PaddingTop(2). + Bold(true). + Render(fmt.Sprintf("Press %s to join", + lipgloss.NewStyle().Italic(true).Render("Enter"))), + ), + ) +} + +func (m PlayModel) renderStartGameContent(base lipgloss.Style) string { + var statusMsg string + + if m.isLoading { + statusMsg = "Loading..." + } else if m.playName != "" { + gameCode := lipgloss.NewStyle(). + Italic(true). + Foreground(lipgloss.Color("#F39C12")). + Render(m.playName) + + statusMsg = fmt.Sprintf("Share `%s` to your friend", gameCode) + } + + return base.Render(statusMsg) +} diff --git a/pkg/ui/views/play_keymap.go b/pkg/ui/views/play_keymap.go new file mode 100644 index 0000000..d5ccc9c --- /dev/null +++ b/pkg/ui/views/play_keymap.go @@ -0,0 +1,137 @@ +package views + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type PlayModelPage int + +const ( + LandingPage PlayModelPage = iota + InsertCodePage + StartGamePage +) + +// Keyboard controls +type playKeyMap struct { + EnterNewGame key.Binding + StartNewGame key.Binding + GoLogout key.Binding + Quit key.Binding + NextPage key.Binding + PrevPage key.Binding +} + +// Default key bindings for the play model +var defaultPlayKeyMap = playKeyMap{ + EnterNewGame: key.NewBinding( + key.WithKeys("alt+E", "alt+e"), + key.WithHelp("Alt+E", "Enter a play using code"), + ), + StartNewGame: key.NewBinding( + key.WithKeys("alt+s", "alt+S"), + key.WithHelp("Alt+S", "Start a new play"), + ), + 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"), + ), + NextPage: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→/h", "Next Page"), + ), + PrevPage: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←/l", "Prev Page"), + ), +} + +func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch { + case key.Matches(msg, m.keys.EnterNewGame): + m.page = InsertCodePage + m.namePrompt, cmd = m.namePrompt.Update("suca") + return m, cmd + + case key.Matches(msg, m.keys.StartNewGame): + m.page = StartGamePage + if !m.isLoading { + m.isLoading = true + return m, m.newGameCallback() + } + + 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 + + case msg.Type == tea.KeyEnter: + if m.page == InsertCodePage && !m.isLoading { + m.isLoading = true + return m, m.enterGame() + } + } + + m.paginator, _ = m.paginator.Update(msg) + + if m.page == InsertCodePage { + m.namePrompt, cmd = m.namePrompt.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m PlayModel) 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) + + if m.page == LandingPage { + enterKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.EnterNewGame.Help().Key), + m.keys.EnterNewGame.Help().Desc) + + startKey := fmt.Sprintf("%s %s", + 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, + ) + } + + return lipgloss.JoinVertical( + lipgloss.Left, + logoutKey, + quitKey, + ) +} diff --git a/pkg/ui/views/play_util.go b/pkg/ui/views/play_util.go new file mode 100644 index 0000000..c57f331 --- /dev/null +++ b/pkg/ui/views/play_util.go @@ -0,0 +1,74 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/boozec/rahanna/internal/api/database" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m PlayModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + return m, nil +} + +func (m *PlayModel) handleError(msg error) (tea.Model, tea.Cmd) { + m.isLoading = false + m.err = msg + return m, nil +} + +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) buildWindowContent(content string, formWidth int) string { + if m.err != nil { + formError := fmt.Sprintf("Error: %v", m.err.Error()) + return lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), + content, + )), + ) + } + + return lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + content, + )), + ) +} |