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} } }