package ui import ( "database/sql" "fmt" "io" "sort" "strings" "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" ) // ─── Messages ───────────────────────────────────────────────────────────────── type BuildingsLoadedMsg struct { Owned []model.AirportBuilding Catalogue []model.Building StorageCount, OperationCount int StorageCap, OperationCap uint32 } type BuildingsErrMsg struct{ Err error } type BuildingConstructedMsg struct{ Name string } type BuildingDemolishedMsg struct{ Fee int64 } type CapUpgradedMsg struct{ Type string } // ─── List items ─────────────────────────────────────────────────────────────── type ownedBuildingItem struct{ b model.AirportBuilding } func (o ownedBuildingItem) Title() string { return buildingEmoji(o.b.BuildingType) + " " + o.b.Name } func (o ownedBuildingItem) Description() string { parts := []string{ style.Muted.Render("type:" + o.b.BuildingType), style.Muted.Render(fmt.Sprintf("lv.%d", o.b.Level)), } if o.b.Capacity.Valid { parts = append(parts, style.FuelChip.Render(fmt.Sprintf("cap:%d", o.b.Capacity.Int32))) } if o.b.OperationType.Valid { parts = append(parts, style.PassengerChip.Render("op:"+o.b.OperationType.String)) } if o.b.OperationSize.Valid { parts = append(parts, style.Muted.Render("size:"+o.b.OperationSize.String)) } return strings.Join(parts, " ") } func (o ownedBuildingItem) FilterValue() string { return o.b.Name } type catalogueItem struct{ b model.Building } func (c catalogueItem) Title() string { return buildingEmoji(c.b.BuildingType) + " " + c.b.Name + style.CashChip.Render(fmt.Sprintf(" ($%d)", c.b.ConstructionCostCash)) } func (c catalogueItem) Description() string { parts := []string{ "type:" + c.b.BuildingType, fmt.Sprintf("min.lv%d", c.b.MinLevel), } if c.b.Capacity.Valid { parts = append(parts, fmt.Sprintf("cap:%d", c.b.Capacity.Int32)) } if c.b.OperationType.Valid { parts = append(parts, "op:"+c.b.OperationType.String) } if c.b.OperationSize.Valid { parts = append(parts, "size:"+c.b.OperationSize.String) } if c.b.PassiveRate.Valid && c.b.PassiveType.Valid { parts = append(parts, fmt.Sprintf("+%d/s %s", c.b.PassiveRate.Int32, c.b.PassiveType.String)) } return style.Muted.Render(strings.Join(parts, " ")) } func (c catalogueItem) FilterValue() string { return c.b.Name } // ─── BuildingsModel ─────────────────────────────────────────────────────────── type BuildingsModel struct { client *service.GameService airportID uint32 ownedList list.Model shopList list.Model subTab int // 0 = owned, 1 = build catalogue owned []model.AirportBuilding catalogue []model.Building storageCount, operationCount int storageCap, operationCap uint32 statusMsg string demolishArmed bool // a demolish confirmation is pending (next y confirms) focused bool width int height int } func NewBuildingsModel(client *service.GameService, airportID uint32) BuildingsModel { mkList := func() list.Model { base := list.NewDefaultDelegate() base.Styles.SelectedTitle = base.Styles.SelectedTitle. Foreground(style.ColorSky). BorderForeground(style.ColorSky) base.Styles.SelectedDesc = base.Styles.SelectedDesc. Foreground(style.ColorPassenger). BorderForeground(style.ColorSky) d := buildingDelegate{DefaultDelegate: base} l := list.New(nil, d, 0, 0) l.Title = "" l.SetShowTitle(false) l.SetShowHelp(false) l.SetShowStatusBar(false) l.SetFilteringEnabled(true) return l } return BuildingsModel{ client: client, airportID: airportID, ownedList: mkList(), shopList: mkList(), } } func (m BuildingsModel) Init() tea.Cmd { return m.load() } func (m BuildingsModel) Update(msg tea.Msg) (BuildingsModel, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height h := m.height - 13 if h < 4 { h = 4 } m.ownedList.SetSize(m.width-4, h) m.shopList.SetSize(m.width-4, h) case BuildingsLoadedMsg: m.owned = msg.Owned m.catalogue = msg.Catalogue m.storageCount, m.operationCount = msg.StorageCount, msg.OperationCount m.storageCap, m.operationCap = msg.StorageCap, msg.OperationCap ownedItems := ownedToItems(msg.Owned) shopItems := catalogueToItems(msg.Catalogue) m.ownedList.SetItems(ownedItems) m.shopList.SetItems(shopItems) m.ownedList.Select(firstSelectable(ownedItems)) m.shopList.Select(firstSelectable(shopItems)) case BuildingConstructedMsg: m.statusMsg = style.Success.String() + "Built: " + msg.Name return m, m.load() case BuildingDemolishedMsg: m.statusMsg = style.Success.String() + fmt.Sprintf("Demolished (−$%d)", msg.Fee) return m, m.load() case CapUpgradedMsg: m.statusMsg = style.Success.String() + msg.Type + " capacity increased!" return m, m.load() case BuildingsErrMsg: m.statusMsg = style.Error.String() + msg.Err.Error() case tea.KeyMsg: if !m.focused { break } // A demolish confirmation is pending: y confirms, anything else cancels. if m.demolishArmed { m.demolishArmed = false if msg.String() == "y" || msg.String() == "Y" { return m, m.demolishSelected() } m.statusMsg = style.Muted.Render("Demolition cancelled.") return m, nil } switch msg.String() { case "h", "left": m.subTab = 0 case "l", "right": m.subTab = 1 case "r": m.statusMsg = "" return m, m.load() case "d", "D": if m.subTab == 0 { item, ok := m.ownedList.SelectedItem().(ownedBuildingItem) if !ok { break } m.demolishArmed = true m.statusMsg = style.Warning.String() + fmt.Sprintf("Demolish %s for $%d (no refund)? (y/n)", item.b.Name, demolishFee(item.b)) return m, nil } case "c", "C": if m.subTab == 1 { item, ok := m.shopList.SelectedItem().(catalogueItem) if !ok { break } return m, m.buyCapUpgrade(item.b.BuildingType) } case "enter", " ": if m.subTab == 1 { return m, m.buildSelected() } } } var cmd tea.Cmd if m.subTab == 0 { m.ownedList, cmd = m.ownedList.Update(msg) } else { m.shopList, cmd = m.shopList.Update(msg) } // Keep the cursor off non-selectable section headers. if key, ok := msg.(tea.KeyMsg); ok { if m.subTab == 0 { skipHeaders(&m.ownedList, key.String()) } else { skipHeaders(&m.shopList, key.String()) } } return m, cmd } func (m BuildingsModel) View() string { // Sub-tab headers ownedTab := style.Muted.Render(" Owned ") buildTab := style.Muted.Render(" Build ") if m.subTab == 0 { ownedTab = style.CashChip.Render(" ▶ Owned ") } else { buildTab = style.CashChip.Render(" ▶ Build ") } tabs := lipgloss.JoinHorizontal(lipgloss.Top, ownedTab, style.Muted.Render(" │ "), buildTab, ) statusLine := "" if m.statusMsg != "" { statusLine = "\n" + m.statusMsg + "\n" } var help string if m.subTab == 0 { help = style.HelpKey.Render("h/l") + style.HelpDesc.Render(" switch ") + style.HelpKey.Render("d") + style.HelpDesc.Render(" demolish ") + style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh ") + style.HelpKey.Render("/") + style.HelpDesc.Render(" filter") } else { help = style.HelpKey.Render("h/l") + style.HelpDesc.Render(" switch ") + style.HelpKey.Render("enter") + style.HelpDesc.Render(" build ") + style.HelpKey.Render("c") + style.HelpDesc.Render(" expand cap ") + style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh") } capLine := style.Muted.Render(fmt.Sprintf("Caps: 📦 storage %d/%d 🛬 operation %d/%d", m.storageCount, m.storageCap, m.operationCount, m.operationCap)) var content string if m.subTab == 0 { if len(m.owned) == 0 { content = style.Muted.Render("No buildings yet. Switch to Build tab (l / →) to construct some.") } else { content = m.ownedList.View() } } else { if len(m.catalogue) == 0 { content = style.Muted.Render("No buildings available in the catalogue.") } else { content = m.shopList.View() } } return lipgloss.JoinVertical(lipgloss.Left, style.PanelHeader.Render("🏗 Buildings"), tabs, capLine, statusLine, content, "", style.Muted.Render(help), ) } // ─── Exported for parent AppModel ───────────────────────────────────────────── // Load is the exported version for use by parent AppModel. func (m BuildingsModel) Load() tea.Cmd { return m.load() } func (m BuildingsModel) load() tea.Cmd { client := m.client airportID := m.airportID return func() tea.Msg { owned, err := client.GetAirportBuildings(airportID) if err != nil { return BuildingsErrMsg{Err: err} } cat, err := client.GetAllBuildings() if err != nil { return BuildingsErrMsg{Err: err} } storageCount, operationCount, err := client.BuildingCounts(airportID) if err != nil { return BuildingsErrMsg{Err: err} } storageCap, operationCap, err := client.GetCaps(airportID) if err != nil { return BuildingsErrMsg{Err: err} } return BuildingsLoadedMsg{ Owned: owned, Catalogue: cat, StorageCount: storageCount, OperationCount: operationCount, StorageCap: storageCap, OperationCap: operationCap, } } } func (m BuildingsModel) buildSelected() tea.Cmd { item, ok := m.shopList.SelectedItem().(catalogueItem) if !ok { return nil } client := m.client airportID := m.airportID b := item.b return func() tea.Msg { if err := client.ConstructBuilding(airportID, b.ID); err != nil { return BuildingsErrMsg{Err: err} } return BuildingConstructedMsg{Name: b.Name} } } func (m BuildingsModel) demolishSelected() tea.Cmd { item, ok := m.ownedList.SelectedItem().(ownedBuildingItem) if !ok { return nil } client := m.client airportID := m.airportID id := item.b.ID return func() tea.Msg { fee, err := client.DemolishBuilding(airportID, id) if err != nil { return BuildingsErrMsg{Err: err} } return BuildingDemolishedMsg{Fee: fee} } } func (m BuildingsModel) buyCapUpgrade(buildingType string) tea.Cmd { client := m.client airportID := m.airportID return func() tea.Msg { if err := client.BuyCapUpgrade(airportID, buildingType); err != nil { return BuildingsErrMsg{Err: err} } return CapUpgradedMsg{Type: buildingType} } } // demolishFee previews the cost to demolish: a quarter of construction cost. // (Authoritative value is computed by DemolishBuilding.) func demolishFee(b model.AirportBuilding) int64 { return int64(b.ConstructionCostCash) / 4 } // ─── Grouping ───────────────────────────────────────────────────────────────── // buildingHeader is a non-actionable section / sub-section label inserted between // grouped building rows. Empty FilterValue so it disappears during text filtering. type buildingHeader struct { label string top bool // true = building-type header, false = storage/operation subtype } func (h buildingHeader) Title() string { return h.label } func (h buildingHeader) Description() string { return "" } func (h buildingHeader) FilterValue() string { return "" } // buildingDelegate renders header rows as plain section labels and delegates // every other row to the default delegate. type buildingDelegate struct{ list.DefaultDelegate } func (d buildingDelegate) Render(w io.Writer, m list.Model, index int, it list.Item) { if h, ok := it.(buildingHeader); ok { if h.top { fmt.Fprint(w, " "+style.PanelHeader.Render(h.label)) } else { fmt.Fprint(w, " "+style.Muted.Render(h.label)) } return } d.DefaultDelegate.Render(w, m, index, it) } // typeRank orders building-type sections. func typeRank(t string) int { switch t { case "storage": return 0 case "operation": return 1 case "passive": return 2 case "support": return 3 case "shop": return 4 case "production": return 5 default: return 6 } } // groupable is one row plus the keys used to sort + section it. type groupable struct { item list.Item typ string subtype string name string } // buildGrouped sorts rows by type → subtype → name and inserts section headers // (a type header for every type; a subtype header within storage/operation). func buildGrouped(rows []groupable) []list.Item { sort.SliceStable(rows, func(i, j int) bool { if ri, rj := typeRank(rows[i].typ), typeRank(rows[j].typ); ri != rj { return ri < rj } if rows[i].subtype != rows[j].subtype { return rows[i].subtype < rows[j].subtype } return rows[i].name < rows[j].name }) var out []list.Item curType, curSub := "", "" first := true for _, r := range rows { if first || r.typ != curType { out = append(out, buildingHeader{label: buildingEmoji(r.typ) + " " + strings.ToUpper(r.typ), top: true}) curType, curSub, first = r.typ, "", false } if (r.typ == "storage" || r.typ == "operation") && r.subtype != "" && r.subtype != curSub { out = append(out, buildingHeader{label: " • " + r.subtype}) curSub = r.subtype } out = append(out, r.item) } return out } // firstSelectable returns the index of the first non-header item (or 0). func firstSelectable(items []list.Item) int { for i, it := range items { if _, isHeader := it.(buildingHeader); !isHeader { return i } } return 0 } // skipHeaders nudges the list cursor off a header row in the travel direction, // so selection always lands on a real building. func skipHeaders(l *list.Model, key string) { if _, isHeader := l.SelectedItem().(buildingHeader); !isHeader { return } up := key == "up" || key == "k" for range l.Items() { if _, isHeader := l.SelectedItem().(buildingHeader); !isHeader { return } if up { l.CursorUp() } else { l.CursorDown() } } } // ─── List converters ────────────────────────────────────────────────────────── func ownedToItems(owned []model.AirportBuilding) []list.Item { rows := make([]groupable, len(owned)) for i, b := range owned { rows[i] = groupable{ item: ownedBuildingItem{b: b}, typ: b.BuildingType, subtype: buildingSubtype(b.BuildingType, b.StorageType, b.OperationType), name: b.Name, } } return buildGrouped(rows) } func catalogueToItems(cat []model.Building) []list.Item { rows := make([]groupable, len(cat)) for i, b := range cat { rows[i] = groupable{ item: catalogueItem{b: b}, typ: b.BuildingType, subtype: buildingSubtype(b.BuildingType, b.StorageType, b.OperationType), name: b.Name, } } return buildGrouped(rows) } // buildingSubtype is the storage_type (storage) or operation_type (operation), // used to sub-group within those sections. func buildingSubtype(buildingType string, storageType, operationType sql.NullString) string { switch buildingType { case "storage": if storageType.Valid { return storageType.String } case "operation": if operationType.Valid { return operationType.String } } return "" } // ─── Helpers ────────────────────────────────────────────────────────────────── func buildingEmoji(buildingType string) string { switch buildingType { case "storage": return "📦" case "operation": return "🛬" case "passive": return "⚡" case "support": return "🔧" case "shop": return "🏪" case "production": return "🏭" default: return "🏢" } }