phase 0.3

This commit is contained in:
portakal 2026-06-04 20:51:20 +03:00
commit 4a94bfc4e7
52 changed files with 11653 additions and 0 deletions

375
internal/ui/app.go Normal file
View file

@ -0,0 +1,375 @@
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}
}
}