559 lines
16 KiB
Go
559 lines
16 KiB
Go
|
|
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 "🏢"
|
|||
|
|
}
|
|||
|
|
}
|