Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions internal/ui/header.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ui

import (
"fmt"
"strings"

"github.com/ericdahl/outpost-404/internal/game"
)

// RenderStatusHeader is the top title strip: game name, day, run profile, status badges.
func RenderStatusHeader(s game.State, profiles game.RunProfiles, _ int) string {
scName := s.ScenarioID
if sc, ok := profiles.FindScenario(s.ScenarioID); ok {
scName = sc.Name
}
diffName := s.DifficultyID
if d, ok := profiles.FindDifficulty(s.DifficultyID); ok {
diffName = d.Name
}
line := fmt.Sprintf("Outpost 404 — Day %d / %d — %s · %s",
s.Day, s.SurvivalWinTarget(), scName, diffName)
if strip := formatStatusStrip(game.StatusBadges(s)); strip != "" {
line += " — " + strings.TrimPrefix(strip, "Status: ")
}
return titleStyle.Render("Outpost 404") + "\n" + line
}
82 changes: 62 additions & 20 deletions internal/ui/layout.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
package ui

// LayoutMode selects the main-screen panel arrangement.
type LayoutMode int

const (
LayoutModeNarrow LayoutMode = iota
LayoutModeMedium
LayoutModeWide
)

// MainLayout describes how the main screen arranges panels for the current terminal size.
type MainLayout struct {
Mode LayoutMode
Stacked bool
OutpostBelow bool
ContentWidth int
LeftWidth int
MiddleWidth int
Expand All @@ -12,40 +23,71 @@ type MainLayout struct {
}

const (
horizontalMinWidth = 96
minPanelWidth = 24
maxLeftWidth = 36
maxMiddleWidth = 42
wideMinWidth = 120
wideMinHeight = 35
mediumMinWidth = 100
mediumMinHeight = 30
minPanelWidth = 24
maxLeftWidth = 36
maxMiddleWidth = 42
)

func MainLayoutFor(termWidth, termHeight int) MainLayout {
_ = termHeight
content := contentWidth(termWidth)
logW := LogViewportWidth(termWidth)
logW := LogViewportWidth(termWidth, termHeight)
l := MainLayout{
ContentWidth: content,
LogInnerWidth: logW,
BoxWidth: boxWidth(termWidth, 80),
CompactKeys: termWidth < 88,
}
if termWidth < horizontalMinWidth {
l.Stacked = true
l.LeftWidth = content
l.MiddleWidth = content
return l
switch {
case termWidth >= wideMinWidth && termHeight >= wideMinHeight:
l.Mode = LayoutModeWide
l.applyWideLayout(termWidth, logW)
case termWidth >= mediumMinWidth && termHeight >= mediumMinHeight:
l.Mode = LayoutModeMedium
l.applyMediumLayout(termWidth, logW, content)
default:
l.Mode = LayoutModeNarrow
l.applyNarrowLayout(content)
}
return l
}

func (l *MainLayout) applyWideLayout(termWidth, logW int) {
l.Stacked = false
l.OutpostBelow = false
logBox := logW + 2
remaining := termWidth - logBox
if remaining < minPanelWidth*2 {
l.Stacked = true
l.LeftWidth = content
l.MiddleWidth = content
return l
l.applyNarrowLayout(l.ContentWidth)
l.Mode = LayoutModeNarrow
return
}
l.LeftWidth = clamp(minPanelWidth, remaining*9/20, maxLeftWidth)
l.MiddleWidth = clamp(minPanelWidth, remaining-l.LeftWidth, maxMiddleWidth)
return l
}

func (l *MainLayout) applyMediumLayout(termWidth, logW, content int) {
l.Stacked = false
l.OutpostBelow = true
l.MiddleWidth = content
logBox := logW + 2
remaining := termWidth - logBox
if remaining < minPanelWidth {
l.applyNarrowLayout(content)
l.Mode = LayoutModeNarrow
return
}
l.LeftWidth = clamp(minPanelWidth, remaining, maxLeftWidth)
}

func (l *MainLayout) applyNarrowLayout(content int) {
l.Stacked = true
l.OutpostBelow = false
l.LeftWidth = content
l.MiddleWidth = content
}

func contentWidth(termWidth int) int {
Expand All @@ -67,19 +109,19 @@ func boxWidth(termWidth, preferred int) int {
return w
}

func LogViewportWidth(termWidth int) int {
if termWidth < horizontalMinWidth {
func LogViewportWidth(termWidth, termHeight int) int {
if termWidth < mediumMinWidth || termHeight < mediumMinHeight {
return clamp(22, termWidth-8, 44)
}
w := 44
if termWidth > 120 {
if termWidth >= wideMinWidth {
return 52
}
return w
}

func LogViewportHeight(termHeight int) int {
h := termHeight - 12
h := termHeight - 14
return clamp(6, h, 14)
}

Expand Down
16 changes: 16 additions & 0 deletions internal/ui/layout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package ui

import "testing"

func TestMainLayout_wideUsesThreeColumns(t *testing.T) {
l := MainLayoutFor(120, 35)
if l.Mode != LayoutModeWide || l.OutpostBelow {
t.Fatalf("got mode=%v outpostBelow=%v", l.Mode, l.OutpostBelow)
}
}

func TestMainLayout_logWidthOnWide(t *testing.T) {
if w := LogViewportWidth(120, 35); w < 44 {
t.Fatalf("log width=%d on wide terminal", w)
}
}
4 changes: 0 additions & 4 deletions internal/ui/resource_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,12 @@ func RenderResourcePanel(s game.State, panelWidth int) string {
barW := resourceBarWidth(panelWidth)

lines := []string{
fmt.Sprintf("Day %d / %d", s.Day, s.SurvivalWinTarget()),
resourceBarLine("Power", s.Power, deltas.Power, barW, game.WarningPowerCriticalAt, game.WarningPowerUrgentAt),
resourceBarLine("Food", s.Food, deltas.Food, barW, game.WarningFoodCriticalAt, game.WarningFoodUrgentAt),
resourceBarLine("Morale", s.Morale, deltas.Morale, barW, game.WarningMoraleCriticalAt, game.WarningMoraleUrgentAt),
fmt.Sprintf("Credits %-4d Pop %d/%d Beacon %d/%d",
s.Credits, s.Population, s.PopulationCap, s.BeaconParts, s.MaxBeaconParts),
}
if strip := formatStatusStrip(game.StatusBadges(s)); strip != "" {
lines = append(lines, "", strip)
}
return title + "\n" + strings.Join(lines, "\n")
}

Expand Down
10 changes: 3 additions & 7 deletions internal/ui/resource_panel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,10 @@ func TestFormatStatusStrip_JoinsBadges(t *testing.T) {
}
}

func TestRenderResourcePanel_IncludesStatusStrip(t *testing.T) {
func TestRenderResourcePanel_IncludesResourceBars(t *testing.T) {
s := game.NewState(game.Content{Buildings: []game.BuildingDef{{ID: "habitat", Name: "Habitat", MaxLevel: 3}}})
s.Food = game.WarningFoodCriticalAt
body := RenderResourcePanel(s, 36)
if !strings.Contains(body, "Status:") {
t.Fatalf("missing status strip:\n%s", body)
}
if !strings.Contains(body, "Power") {
t.Fatalf("missing power bar:\n%s", body)
if !strings.Contains(body, "Power") || !strings.Contains(body, "Food") {
t.Fatalf("missing resource bars:\n%s", body)
}
}
2 changes: 1 addition & 1 deletion internal/ui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.TermWidth = msg.Width
m.TermHeight = msg.Height
m.LogViewport.Width = LogViewportWidth(msg.Width)
m.LogViewport.Width = LogViewportWidth(msg.Width, msg.Height)
m.LogViewport.Height = LogViewportHeight(msg.Height)
m.LogViewport = syncLogViewport(m.LogViewport, m.State.Log)
if m.Started {
Expand Down
18 changes: 14 additions & 4 deletions internal/ui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,24 @@ func (m Model) mainView() string {
"Event Log\n" + mutedStyle.Render("↑↓ scroll") + "\n" + m.LogViewport.View(),
)

header := RenderStatusHeader(m.State, m.Profiles, m.TermWidth)
var panels string
if layout.Stacked {
panels = lipgloss.JoinVertical(lipgloss.Left, left, middle, logPanel)
} else {
switch layout.Mode {
case LayoutModeMedium:
top := lipgloss.JoinHorizontal(lipgloss.Top, left, logPanel)
panels = lipgloss.JoinVertical(lipgloss.Left, top, middle)
case LayoutModeWide:
panels = lipgloss.JoinHorizontal(lipgloss.Top, left, middle, logPanel)
default:
parts := []string{left}
if strip := formatStatusStrip(game.StatusBadges(m.State)); strip != "" {
parts = append(parts, boxStyle.Width(layout.ContentWidth).Render(strip))
}
parts = append(parts, middle, logPanel)
panels = lipgloss.JoinVertical(lipgloss.Left, parts...)
}

return titleStyle.Render("Outpost 404") + "\n" + mutedStyle.Render("A tiny terminal base builder by default, a future colony sim by design.") + "\n\n" + panels + "\n\n" + mainActions(layout.CompactKeys)
return header + "\n\n" + panels + "\n\n" + mainActions(layout.CompactKeys)
}

func mainActions(compact bool) string {
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/widgets.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func buildListHeight(itemCount int) int {
}

func newLogViewport(termWidth, termHeight int) viewport.Model {
vp := viewport.New(LogViewportWidth(termWidth), LogViewportHeight(termHeight))
vp := viewport.New(LogViewportWidth(termWidth, termHeight), LogViewportHeight(termHeight))
vp.Style = lipgloss.NewStyle()
return vp
}
Expand Down
56 changes: 36 additions & 20 deletions test/internal/ui/layout_test.go
Original file line number Diff line number Diff line change
@@ -1,40 +1,56 @@
package ui_test

import (
"strings"
"testing"

"github.com/ericdahl/outpost-404/internal/game"
"github.com/ericdahl/outpost-404/internal/ui"
)

func TestMainLayout_wideTerminalUsesHorizontalPanels(t *testing.T) {
l := ui.MainLayoutFor(120, 30)
if l.Stacked {
t.Fatal("expected horizontal layout at 120 columns")
func TestMainLayout_wideAt120x35(t *testing.T) {
l := ui.MainLayoutFor(120, 35)
if l.Mode != ui.LayoutModeWide {
t.Fatalf("mode = %v, want wide", l.Mode)
}
if l.LeftWidth < 28 || l.MiddleWidth < 28 {
t.Fatalf("panel widths too small: left=%d mid=%d", l.LeftWidth, l.MiddleWidth)
if l.Stacked || l.OutpostBelow {
t.Fatalf("wide should be 3-column row: stacked=%v outpostBelow=%v", l.Stacked, l.OutpostBelow)
}
if l.LogInnerWidth < 44 {
t.Fatalf("log width=%d, want at least 44 on wide terminal", l.LogInnerWidth)
}

func TestMainLayout_mediumAt100x30(t *testing.T) {
l := ui.MainLayoutFor(100, 30)
if l.Mode != ui.LayoutModeMedium {
t.Fatalf("mode = %v, want medium", l.Mode)
}
if !l.OutpostBelow || l.Stacked {
t.Fatalf("medium want outpost below: stacked=%v outpostBelow=%v", l.Stacked, l.OutpostBelow)
}
}

func TestMainLayout_narrowTerminalStacksPanels(t *testing.T) {
l := ui.MainLayoutFor(70, 24)
if !l.Stacked {
t.Fatal("expected stacked layout below horizontal breakpoint")
func TestMainLayout_narrowAt80x24(t *testing.T) {
l := ui.MainLayoutFor(80, 24)
if l.Mode != ui.LayoutModeNarrow {
t.Fatalf("mode = %v, want narrow", l.Mode)
}
if l.ContentWidth < 60 {
t.Fatalf("content width=%d, want most of terminal width", l.ContentWidth)
if !l.Stacked {
t.Fatal("narrow should stack panels")
}
}

func TestMainLayout_logViewportShrinksOnNarrowTerminal(t *testing.T) {
w := ui.LogViewportWidth(60)
if w > 52 {
t.Fatalf("log viewport width=%d, should shrink with terminal", w)
func TestRenderStatusHeader_IncludesDayAndScenario(t *testing.T) {
s := game.NewState(game.Content{})
s.Day = 18
s.ScenarioID = "standard"
s.DifficultyID = "normal"
p := game.RunProfiles{
Scenarios: []game.ScenarioDef{{ID: "standard", Name: "Standard Landing"}},
Difficulties: []game.DifficultyDef{{ID: "normal", Name: "Normal"}},
}
if w < 20 {
t.Fatalf("log viewport width=%d, too narrow to read", w)
h := ui.RenderStatusHeader(s, p, 100)
for _, want := range []string{"Outpost 404", "Day 18", "Standard Landing", "Normal"} {
if !strings.Contains(h, want) {
t.Fatalf("missing %q in %q", want, h)
}
}
}