375 lines
10 KiB
Go
375 lines
10 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/list"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"skyrama-tui/internal/model"
|
|
"skyrama-tui/internal/service"
|
|
"skyrama-tui/internal/style"
|
|
)
|
|
|
|
type tickMsg time.Time
|
|
|
|
func tick() tea.Cmd {
|
|
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
|
}
|
|
|
|
type AirportFetchedMsg struct{ Airport *model.Airport }
|
|
type AirportFetchErrMsg struct{ Err error }
|
|
type CountriesFetchedMsg struct{ Countries []model.Country }
|
|
|
|
type AppModel struct {
|
|
client *service.GameService
|
|
userID int32
|
|
username string
|
|
|
|
airport *model.Airport
|
|
countries map[uint16]string
|
|
|
|
activeTab int
|
|
fleet FleetModel
|
|
buildings BuildingsModel
|
|
contracts ContractsModel
|
|
shop ShopModel
|
|
|
|
width int
|
|
height int
|
|
globalErr string
|
|
loading bool
|
|
}
|
|
|
|
func NewAppModel(client *service.GameService, userID int32, username string) AppModel {
|
|
return AppModel{
|
|
client: client,
|
|
userID: userID,
|
|
username: username,
|
|
countries: make(map[uint16]string),
|
|
loading: true,
|
|
}
|
|
}
|
|
|
|
func (m AppModel) Init() tea.Cmd {
|
|
return tea.Batch(m.fetchAirport(), m.fetchCountries(), tick(), m.fleet.LoadDestinations())
|
|
}
|
|
|
|
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
h := m.contentHeight()
|
|
m.fleet.width = msg.Width
|
|
m.fleet.height = h
|
|
m.buildings.width = msg.Width
|
|
m.buildings.height = h
|
|
m.buildings.ownedList.SetSize(msg.Width-4, h-8)
|
|
m.buildings.shopList.SetSize(msg.Width-4, h-8)
|
|
m.shop.width = msg.Width
|
|
m.shop.height = h
|
|
m.shop.list.SetSize(msg.Width-4, h-7)
|
|
// return immediately — no need to forward resize to sub-models twice
|
|
return m, nil
|
|
|
|
case tickMsg:
|
|
cmds = append(cmds, m.fetchAirport(), m.fleet.CheckLanding(), m.fleet.CheckServiceProgress(), tick())
|
|
|
|
case AirportFetchedMsg:
|
|
m.loading = false
|
|
m.airport = msg.Airport
|
|
m.globalErr = ""
|
|
if m.airport != nil {
|
|
m.shop.playerLevel = m.airport.PlayerLevel
|
|
m.shop.airportID = m.airport.ID
|
|
m.fleet.airportID = m.airport.ID
|
|
m.buildings.airportID = m.airport.ID
|
|
m.contracts.airportID = m.airport.ID
|
|
}
|
|
|
|
case AirportFetchErrMsg:
|
|
m.loading = false
|
|
m.globalErr = msg.Err.Error()
|
|
|
|
case CountriesFetchedMsg:
|
|
for _, c := range msg.Countries {
|
|
m.countries[c.ID] = c.Name
|
|
}
|
|
|
|
// Fleet messages must always reach fleet regardless of the active tab,
|
|
// so auto-landing and fleet reloads work while the user browses other tabs.
|
|
case FlightLaunchedMsg, FlightLandedMsg, FleetLoadedMsg, FleetErrMsg, ServiceProgressMsg, PlaneSoldMsg, DispatchResultMsg, DestinationsLoadedMsg:
|
|
updated, cmd := m.fleet.Update(msg)
|
|
m.fleet = updated
|
|
cmds = append(cmds, cmd)
|
|
// Reload the airport header after resource-changing actions.
|
|
switch msg.(type) {
|
|
case FlightLandedMsg, PlaneSoldMsg, DispatchResultMsg:
|
|
cmds = append(cmds, m.fetchAirport())
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
|
|
// Buildings/shop result messages always reach their sub-model so a reload
|
|
// fires even if the user switched tabs, and refresh the airport header.
|
|
case BuildingDemolishedMsg, CapUpgradedMsg, BuildingConstructedMsg:
|
|
updated, cmd := m.buildings.Update(msg)
|
|
m.buildings = updated
|
|
cmds = append(cmds, cmd, m.fetchAirport())
|
|
return m, tea.Batch(cmds...)
|
|
|
|
case PlaneBoughtMsg:
|
|
updated, cmd := m.shop.Update(msg)
|
|
m.shop = updated
|
|
cmds = append(cmds, cmd, m.fetchAirport())
|
|
return m, tea.Batch(cmds...)
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
case "q":
|
|
// Don't quit if a list sub-model is in filter mode, or the fleet
|
|
// dispatch modal is open (it consumes q to cancel).
|
|
if m.fleet.dispatchMode ||
|
|
m.buildings.ownedList.FilterState() == list.Filtering ||
|
|
m.buildings.shopList.FilterState() == list.Filtering ||
|
|
m.shop.list.FilterState() == list.Filtering {
|
|
break
|
|
}
|
|
return m, tea.Quit
|
|
case "1":
|
|
return m.switchTab(model.TabAirport), nil
|
|
case "2":
|
|
m2 := m.switchTab(model.TabFleet)
|
|
return m2, m2.fleet.LoadFleet()
|
|
case "3":
|
|
m2 := m.switchTab(model.TabBuildings)
|
|
return m2, m2.buildings.Load()
|
|
case "4":
|
|
m2 := m.switchTab(model.TabContracts)
|
|
return m2, m2.contracts.Load()
|
|
case "5":
|
|
m2 := m.switchTab(model.TabShop)
|
|
return m2, m2.shop.Load()
|
|
case "tab":
|
|
next := (m.activeTab + 1) % len(model.TabNames)
|
|
m2 := m.switchTab(next)
|
|
return m2, m2.tabRefreshCmd(next)
|
|
case "shift+tab":
|
|
prev := (m.activeTab - 1 + len(model.TabNames)) % len(model.TabNames)
|
|
m2 := m.switchTab(prev)
|
|
return m2, m2.tabRefreshCmd(prev)
|
|
}
|
|
}
|
|
|
|
switch m.activeTab {
|
|
case model.TabFleet:
|
|
updated, cmd := m.fleet.Update(msg)
|
|
m.fleet = updated
|
|
cmds = append(cmds, cmd)
|
|
case model.TabBuildings:
|
|
updated, cmd := m.buildings.Update(msg)
|
|
m.buildings = updated
|
|
cmds = append(cmds, cmd)
|
|
case model.TabContracts:
|
|
updated, cmd := m.contracts.Update(msg)
|
|
m.contracts = updated
|
|
cmds = append(cmds, cmd)
|
|
case model.TabShop:
|
|
updated, cmd := m.shop.Update(msg)
|
|
m.shop = updated
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m AppModel) View() string {
|
|
//if m.width == 0 {
|
|
// return "Initializing…"
|
|
//}
|
|
if m.loading {
|
|
return lipgloss.Place(m.width, m.height,
|
|
lipgloss.Center, lipgloss.Center,
|
|
style.PanelHeader.Render("Connecting to database…"))
|
|
}
|
|
|
|
tabs := m.renderTabBar()
|
|
content := style.Panel.
|
|
Width(m.width - 2).
|
|
Height(m.contentHeight()).
|
|
MaxHeight(m.contentHeight() + 2).
|
|
Render(m.renderContent())
|
|
view := lipgloss.JoinVertical(lipgloss.Left, tabs, content, m.renderStatusBar())
|
|
// Clamp to the terminal height so an over-tall tab can never push the frame
|
|
// past the screen and leave stale lines from the previous tab visible.
|
|
if m.height > 0 {
|
|
view = lipgloss.NewStyle().MaxHeight(m.height).Render(view)
|
|
}
|
|
return view
|
|
}
|
|
|
|
func (m AppModel) contentHeight() int {
|
|
h := m.height - 4
|
|
if h < 4 {
|
|
h = 4
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m AppModel) renderTabBar() string {
|
|
var tabs []string
|
|
for i, name := range model.TabNames {
|
|
if i == m.activeTab {
|
|
tabs = append(tabs, lipgloss.NewStyle().
|
|
Foreground(style.ColorSky).Bold(true).
|
|
Background(style.ColorHighlight).
|
|
Padding(0, 1).Render(name))
|
|
} else {
|
|
tabs = append(tabs, lipgloss.NewStyle().
|
|
Foreground(style.ColorMuted).Padding(0, 1).Render(name))
|
|
}
|
|
}
|
|
bar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
|
user := style.Muted.Render(" " + m.username + " ")
|
|
errStr := ""
|
|
if m.globalErr != "" {
|
|
errStr = " " + style.Warning.String() + style.Muted.Render(m.globalErr) + " "
|
|
}
|
|
spacerW := m.width - lipgloss.Width(bar) - lipgloss.Width(user) - lipgloss.Width(errStr)
|
|
if spacerW < 0 {
|
|
spacerW = 0
|
|
}
|
|
return lipgloss.NewStyle().Background(style.ColorDark).Width(m.width).
|
|
Render(lipgloss.JoinHorizontal(lipgloss.Top,
|
|
bar,
|
|
lipgloss.NewStyle().Width(spacerW).Render(""),
|
|
errStr,
|
|
user,
|
|
))
|
|
}
|
|
|
|
func (m AppModel) renderContent() string {
|
|
switch m.activeTab {
|
|
case model.TabAirport:
|
|
country := ""
|
|
if m.airport != nil {
|
|
country = m.countries[m.airport.OriginCountryID]
|
|
}
|
|
return AirportView(m.airport, country, m.width-4)
|
|
case model.TabFleet:
|
|
return m.fleet.View()
|
|
case model.TabBuildings:
|
|
return m.buildings.View()
|
|
case model.TabContracts:
|
|
return m.contracts.View()
|
|
case model.TabShop:
|
|
return m.shop.View()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m AppModel) renderStatusBar() string {
|
|
cash, fuel, pax := "—", "—", "—"
|
|
if m.airport != nil {
|
|
cash = fmt.Sprintf("💰 $%s", formatCash(m.airport.Cash))
|
|
fuel = fmt.Sprintf("⛽ %d/%d", m.airport.CurrentFuel, m.airport.MaxFuelCapacity)
|
|
pax = fmt.Sprintf("👤 %d/%d", m.airport.CurrentPassengers, m.airport.MaxPassengerCapacity)
|
|
}
|
|
left := style.StatusBar.Render(fmt.Sprintf(" %s %s %s ", cash, fuel, pax))
|
|
right := style.StatusBarKey.Render(" 1-5 ") + style.StatusBar.Render(" tabs ") +
|
|
style.StatusBarKey.Render(" tab ") + style.StatusBar.Render(" cycle ") +
|
|
style.StatusBarKey.Render(" q ") + style.StatusBar.Render(" quit ")
|
|
spacerW := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
|
if spacerW < 0 {
|
|
spacerW = 0
|
|
}
|
|
return lipgloss.JoinHorizontal(lipgloss.Top,
|
|
left,
|
|
lipgloss.NewStyle().Background(style.ColorHighlight).Width(spacerW).Render(""),
|
|
right,
|
|
)
|
|
}
|
|
|
|
func (m AppModel) switchTab(tab int) AppModel {
|
|
m.fleet.focused = false
|
|
m.fleet.statusMsg = ""
|
|
m.fleet.sellArmed = false
|
|
m.buildings.focused = false
|
|
m.buildings.statusMsg = ""
|
|
m.buildings.demolishArmed = false
|
|
m.contracts.focused = false
|
|
m.contracts.statusMsg = ""
|
|
m.shop.focused = false
|
|
m.shop.statusMsg = ""
|
|
m.activeTab = tab
|
|
switch tab {
|
|
case model.TabFleet:
|
|
m.fleet.focused = true
|
|
case model.TabBuildings:
|
|
m.buildings.focused = true
|
|
case model.TabContracts:
|
|
m.contracts.focused = true
|
|
case model.TabShop:
|
|
m.shop.focused = true
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m AppModel) tabRefreshCmd(tab int) tea.Cmd {
|
|
switch tab {
|
|
case model.TabFleet:
|
|
return m.fleet.LoadFleet()
|
|
case model.TabBuildings:
|
|
return m.buildings.Load()
|
|
case model.TabContracts:
|
|
return m.contracts.Load()
|
|
case model.TabShop:
|
|
return m.shop.Load()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *AppModel) Fleet(f *FleetModel) { m.fleet = *f }
|
|
func (m *AppModel) SetBuildings(b *BuildingsModel) { m.buildings = *b }
|
|
func (m *AppModel) SetContracts(c *ContractsModel) { m.contracts = *c }
|
|
func (m *AppModel) SetShop(s *ShopModel) { m.shop = *s }
|
|
|
|
func (m AppModel) fetchAirport() tea.Cmd {
|
|
client := m.client
|
|
userID := m.userID
|
|
airportID := uint32(0)
|
|
if m.airport != nil {
|
|
airportID = m.airport.ID
|
|
}
|
|
return func() tea.Msg {
|
|
airport, err := client.GetAirportByUserID(userID)
|
|
if err != nil {
|
|
return AirportFetchErrMsg{Err: fmt.Errorf("no airport yet — create one via db_manager")}
|
|
}
|
|
if airportID != 0 {
|
|
_ = client.RefreshResources(airportID)
|
|
if a2, err2 := client.GetAirportByUserID(userID); err2 == nil {
|
|
airport = a2
|
|
}
|
|
}
|
|
return AirportFetchedMsg{Airport: airport}
|
|
}
|
|
}
|
|
|
|
func (m AppModel) fetchCountries() tea.Cmd {
|
|
client := m.client
|
|
return func() tea.Msg {
|
|
countries, err := client.GetAllCountries()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return CountriesFetchedMsg{Countries: countries}
|
|
}
|
|
}
|