diff --git a/internal/ui/header.go b/internal/ui/header.go new file mode 100644 index 0000000..7dda7ce --- /dev/null +++ b/internal/ui/header.go @@ -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 +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 075d67c..020b696 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -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 @@ -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 { @@ -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) } diff --git a/internal/ui/layout_test.go b/internal/ui/layout_test.go new file mode 100644 index 0000000..accec7f --- /dev/null +++ b/internal/ui/layout_test.go @@ -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) + } +} diff --git a/internal/ui/resource_panel.go b/internal/ui/resource_panel.go index 0c628de..be44d43 100644 --- a/internal/ui/resource_panel.go +++ b/internal/ui/resource_panel.go @@ -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") } diff --git a/internal/ui/resource_panel_test.go b/internal/ui/resource_panel_test.go index d617a78..7a09a0e 100644 --- a/internal/ui/resource_panel_test.go +++ b/internal/ui/resource_panel_test.go @@ -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) } } diff --git a/internal/ui/update.go b/internal/ui/update.go index 37e37ec..0f7f038 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -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 { diff --git a/internal/ui/view.go b/internal/ui/view.go index c394fd2..7bc2f8b 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -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 { diff --git a/internal/ui/widgets.go b/internal/ui/widgets.go index 94f808c..aca2436 100644 --- a/internal/ui/widgets.go +++ b/internal/ui/widgets.go @@ -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 } diff --git a/test/internal/ui/layout_test.go b/test/internal/ui/layout_test.go index 640e3a3..df2c69a 100644 --- a/test/internal/ui/layout_test.go +++ b/test/internal/ui/layout_test.go @@ -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) + } } }