summaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
Diffstat (limited to 'pkg')
-rw-r--r--pkg/ui/views/game.go176
-rw-r--r--pkg/ui/views/game_api.go59
-rw-r--r--pkg/ui/views/game_keymap.go54
-rw-r--r--pkg/ui/views/game_moves.go66
-rw-r--r--pkg/ui/views/game_util.go28
-rw-r--r--pkg/ui/views/play.go483
-rw-r--r--pkg/ui/views/play_api.go221
-rw-r--r--pkg/ui/views/play_content.go87
-rw-r--r--pkg/ui/views/play_keymap.go137
-rw-r--r--pkg/ui/views/play_util.go74
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,
+ )),
+ )
+}