From 33b74e6088e7ac96ffdd32526eaef848e731cdb8 Mon Sep 17 00:00:00 2001 From: MCFeuer Date: Tue, 14 Jul 2026 12:02:57 +0200 Subject: [PATCH] Stabilize sidequest pane sizing --- AGENTS.MD | 2 +- internal/game/shell.go | 28 +++++++--- internal/game/shell_test.go | 80 +++++++++++++++++++++++++++- internal/preflight/preflight.go | 2 +- internal/preflight/preflight_test.go | 4 +- internal/tmux/tmux.go | 51 ++++++++++++++++-- internal/tmux/tmux_test.go | 70 ++++++++++++++++++++++++ 7 files changed, 222 insertions(+), 15 deletions(-) diff --git a/AGENTS.MD b/AGENTS.MD index e77a482..65efa0a 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -249,7 +249,7 @@ Required behavior: The MVP requires: ```text -Minimum terminal size: 80 columns × 24 rows +Minimum terminal size: 80 columns × 22 rows Minimum usable game area: 40 columns × 10 rows ``` diff --git a/internal/game/shell.go b/internal/game/shell.go index c698a27..0413df0 100644 --- a/internal/game/shell.go +++ b/internal/game/shell.go @@ -1168,7 +1168,7 @@ func drawResultPanel(screen tcell.Screen, view viewState, baseStyle tcell.Style) return } - lines := resultPanelLines(view, arena.RenderWidth()) + lines := resultPanelLines(view, arena.RenderWidth(), arena.Height) if len(lines) == 0 { return } @@ -1305,7 +1305,7 @@ func drawCompletionDecisionPanel(screen tcell.Screen, view viewState, baseStyle } } -func resultPanelLines(view viewState, maxWidth int) []string { +func resultPanelLines(view viewState, maxWidth int, maxHeight int) []string { score := view.ResultScore if score == 0 && view.PendingScore == nil && !view.RoundFinalized { if view.Quest.Enabled() && view.FinalScore.FinalScore > 0 { @@ -1314,8 +1314,12 @@ func resultPanelLines(view viewState, maxWidth int) []string { score = view.Game.Score } } + maxContentLines := maxHeight - 2 + if maxContentLines < 1 { + maxContentLines = 1 + } if view.PendingScore != nil { - if maxWidth < 34 { + if maxWidth < 34 || maxContentLines < 8 { return []string{ fmt.Sprintf("NEW HIGH SCORE %d", view.PendingScore.Score), "NAME [" + truncateDisplay(view.PendingScore.Input, maxWidth-8) + "]", @@ -1335,17 +1339,27 @@ func resultPanelLines(view viewState, maxWidth int) []string { } title := "GAME OVER" - action := "R Restart F9 Hide" + action := "R Restart F10 Shell" if view.Frozen { title = "COMMAND FINISHED" action = "F10 Shell" } - if maxWidth < 34 { + if maxWidth < 34 || maxContentLines < 11 { lines := []string{ title, - fmt.Sprintf("%d", score), + fmt.Sprintf("SCORE %d", score), + } + entrySlots := maxContentLines - len(lines) - 1 + if view.StatsMessage != "" { + entrySlots-- + } + if entrySlots > 0 { + entries := leaderboardLines(view.Leaderboard, view.CurrentRank, maxWidth-2) + if len(entries) > entrySlots { + entries = entries[:entrySlots] + } + lines = append(lines, entries...) } - lines = append(lines, leaderboardLines(view.Leaderboard, view.CurrentRank, maxWidth-2)...) lines = append(lines, action) if view.StatsMessage != "" { lines = append(lines, truncateDisplay(view.StatsMessage, maxWidth-2)) diff --git a/internal/game/shell_test.go b/internal/game/shell_test.go index 297e9b5..0301f87 100644 --- a/internal/game/shell_test.go +++ b/internal/game/shell_test.go @@ -899,7 +899,7 @@ func TestRunShowsCenteredResultPanelAfterRoundOver(t *testing.T) { waitForRenderedText(t, screen, "NEW HIGH SCORE") screen.PostEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) waitForRenderedText(t, screen, "GAME OVER") - waitForRenderedText(t, screen, "FINAL SCORE 0") + waitForRenderedText(t, screen, "FINAL SCORE") waitForRenderedText(t, screen, "TOP 5") waitForRenderedText(t, screen, "R Restart") cancelShell(t, cancel, errc) @@ -1324,6 +1324,75 @@ func TestRenderDrawsMonochromeQuestObjectsAndResultPanel(t *testing.T) { assertScreenHasNoColors(t, screen) } +func TestResultPanelLinesShowFullLeaderboardWhenTall(t *testing.T) { + view := viewState{ + ResultScore: 920, + RoundFinalized: true, + Leaderboard: []LeaderboardEntry{ + {Score: 920, PlayerName: "one"}, + {Score: 800, PlayerName: "two"}, + {Score: 700, PlayerName: "three"}, + {Score: 600, PlayerName: "four"}, + {Score: 500, PlayerName: "five"}, + }, + CurrentRank: 1, + } + + lines := resultPanelLines(view, 78, 13) + + for _, want := range []string{"GAME OVER", "FINAL SCORE 920", "TOP 5", "5.", "R Restart", "F10 Shell"} { + if !linesContain(lines, want) { + t.Fatalf("result panel lines missing %q: %#v", want, lines) + } + } + if len(lines) > 11 { + t.Fatalf("result panel lines len=%d, want at most 11: %#v", len(lines), lines) + } +} + +func TestResultPanelLinesKeepActionsVisibleWhenShort(t *testing.T) { + view := viewState{ + ResultScore: 920, + RoundFinalized: true, + Leaderboard: []LeaderboardEntry{ + {Score: 920, PlayerName: "one"}, + {Score: 800, PlayerName: "two"}, + {Score: 700, PlayerName: "three"}, + {Score: 600, PlayerName: "four"}, + {Score: 500, PlayerName: "five"}, + }, + CurrentRank: 1, + } + + lines := resultPanelLines(view, 78, 8) + + for _, want := range []string{"GAME OVER", "SCORE 920", "R Restart", "F10 Shell"} { + if !linesContain(lines, want) { + t.Fatalf("compact result panel lines missing %q: %#v", want, lines) + } + } + if len(lines) > 6 { + t.Fatalf("compact result panel lines len=%d, want at most 6: %#v", len(lines), lines) + } +} + +func TestResultPanelLinesKeepHighscoreInputVisibleWhenShort(t *testing.T) { + view := viewState{ + PendingScore: &PendingHighscore{Score: 920, Input: "mcfeuer"}, + } + + lines := resultPanelLines(view, 78, 8) + + for _, want := range []string{"NEW HIGH SCORE 920", "NAME [mcfeuer]", "Enter confirm"} { + if !linesContain(lines, want) { + t.Fatalf("compact highscore lines missing %q: %#v", want, lines) + } + } + if len(lines) > 6 { + t.Fatalf("compact highscore lines len=%d, want at most 6: %#v", len(lines), lines) + } +} + func runShellCancellable(shell Shell) (context.CancelFunc, chan error) { ctx, cancel := context.WithCancel(context.Background()) errc := make(chan error, 1) @@ -1341,6 +1410,15 @@ func cancelShell(t *testing.T, cancel context.CancelFunc, errc <-chan error) { } } +func linesContain(lines []string, want string) bool { + for _, line := range lines { + if strings.Contains(line, want) { + return true + } + } + return false +} + func waitForRenderedText(t *testing.T, screen tcell.SimulationScreen, want string) { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index c3a1250..7f07d46 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -10,7 +10,7 @@ import ( const ( MinimumColumns = 80 - MinimumRows = 16 + MinimumRows = 22 ) var ( diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go index 6f042e0..21acf06 100644 --- a/internal/preflight/preflight_test.go +++ b/internal/preflight/preflight_test.go @@ -71,7 +71,7 @@ func TestValidateRejectsUnavailableTerminalSize(t *testing.T) { func TestValidateRejectsSmallTerminal(t *testing.T) { env := validEnvironment() env.TerminalSize = func(uintptr) (Size, error) { - return Size{Columns: 80, Rows: 15}, nil + return Size{Columns: 80, Rows: 21}, nil } err := Validate(env) @@ -80,7 +80,7 @@ func TestValidateRejectsSmallTerminal(t *testing.T) { } message := err.Error() - for _, fragment := range []string{"required at least 80x16", "detected 80x15"} { + for _, fragment := range []string{"required at least 80x22", "detected 80x21"} { if !strings.Contains(message, fragment) { t.Fatalf("error %q does not contain %q", message, fragment) } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index dbc9150..f9c7c79 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -46,6 +46,13 @@ type Info struct { const commandPaneHistoryLimit = 100000 const commandPanePreviewLines = 20 +const ( + defaultGamePaneHeight = 16 + // 24 arena rows plus HUD/top offset and the bottom wall. + gamePaneMaxHeight = 30 + commandPaneMinHeight = 6 +) + const ( bossHiddenOption = "@sidequest_boss_hidden" bossPrevGameOption = "@sidequest_boss_prev_game" @@ -99,9 +106,11 @@ func (l Layout) Start(runtimeSession session.Session, commandRunner []string, ga } ui := uiPresetForSession(runtimeSession) + terminalRows := 0 newSessionArgs := []string{"new-session", "-d"} if columns, rows, err := l.currentTerminalSize(); err == nil && columns > 0 && rows > 0 { newSessionArgs = append(newSessionArgs, "-x", strconv.Itoa(columns), "-y", strconv.Itoa(rows)) + terminalRows = rows } newSessionArgs = append(newSessionArgs, "-s", info.SessionName, "-n", "sidequest", shellJoin(commandRunner)) if err := run(newSessionArgs...); err != nil { @@ -154,19 +163,22 @@ func (l Layout) Start(runtimeSession session.Session, commandRunner []string, ga return cleanup(fmt.Errorf("enable command pane remain-on-exit: %w", err)) } runOptional("set-option", "-t", info.SessionName, "remain-on-exit-format", remainOnExitFormat) - if err := run("split-window", "-v", "-l", "16", "-t", info.SessionName+":0.0", shellJoin(gameRunner)); err != nil { + if err := run("split-window", "-v", "-l", strconv.Itoa(gamePaneHeightForWindow(terminalRows)), "-t", info.SessionName+":0.0", shellJoin(gameRunner)); err != nil { return cleanup(fmt.Errorf("create placeholder pane: %w", err)) } if err := run("select-pane", "-t", info.SessionName+":0.1", "-T", gamePaneTitle); err != nil { return cleanup(fmt.Errorf("title game pane: %w", err)) } + if err := run("set-hook", "-t", info.SessionName, "window-resized", gamePaneResizeCommand(tmuxPath, info)); err != nil { + return cleanup(fmt.Errorf("configure game pane resize hook: %w", err)) + } if err := run("bind-key", "-n", "F12", "select-pane", "-t", ":.+"); err != nil { return cleanup(fmt.Errorf("bind F12 pane switch: %w", err)) } if err := run("bind-key", "-n", "F10", "detach-client"); err != nil { return cleanup(fmt.Errorf("bind F10 detach: %w", err)) } - if err := run("bind-key", "-n", "F9", "if-shell", "-F", "#{==:#{"+bossHiddenOption+"},1}", bossRestoreCommand(info), bossHideCommand(info)); err != nil { + if err := run("bind-key", "-n", "F9", "if-shell", "-F", "#{==:#{"+bossHiddenOption+"},1}", bossRestoreCommand(tmuxPath, info), bossHideCommand(info)); err != nil { return cleanup(fmt.Errorf("bind F9 boss key: %w", err)) } for _, binding := range commandPaneScrollBindings() { @@ -193,6 +205,38 @@ func (l Layout) currentTerminalSize() (columns int, rows int, err error) { return size.Columns, size.Rows, nil } +func gamePaneHeightForWindow(windowHeight int) int { + if windowHeight <= 0 { + return defaultGamePaneHeight + } + maxAllowed := windowHeight - commandPaneMinHeight + if maxAllowed < 1 { + return 1 + } + if maxAllowed < gamePaneMaxHeight { + return maxAllowed + } + return gamePaneMaxHeight +} + +func gamePaneResizeCommand(tmuxPath string, info Info) string { + shell := fmt.Sprintf( + "h=#{window_height}; z=#{window_zoomed_flag}; "+ + "case \"$h\" in ''|*[!0-9]*) exit 0;; esac; "+ + "[ \"$z\" = 1 ] && exit 0; "+ + "target=%d; max_for_game=$((h - %d)); "+ + "[ \"$max_for_game\" -lt 1 ] && max_for_game=1; "+ + "[ \"$max_for_game\" -lt \"$target\" ] && target=\"$max_for_game\"; "+ + "%s -f /dev/null -L %s resize-pane -t %s -y \"$target\"", + gamePaneMaxHeight, + commandPaneMinHeight, + shellQuote(tmuxPath), + shellQuote(info.SocketName), + shellQuote(info.SessionName+":0.1"), + ) + return "run-shell -b " + shellQuote(shell) +} + type commandPaneScrollBinding struct { key string command string @@ -225,9 +269,10 @@ func bossHideCommand(info Info) string { }, " ; ") } -func bossRestoreCommand(info Info) string { +func bossRestoreCommand(tmuxPath string, info Info) string { return strings.Join([]string{ fmt.Sprintf("if-shell -F '#{window_zoomed_flag}' 'resize-pane -Z -t %s:0.0' ''", info.SessionName), + gamePaneResizeCommand(tmuxPath, info), fmt.Sprintf("set-option -q -t %s pane-border-status %s", info.SessionName, paneBorderStatus), fmt.Sprintf("if-shell -F '#{==:#{%s},1}' 'select-pane -t %s:0.1' 'select-pane -t %s:0.0'", bossPrevGameOption, info.SessionName, info.SessionName), fmt.Sprintf("set-option -q -t %s %s 0", info.SessionName, bossHiddenOption), diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 400f38a..e18d9d8 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -49,6 +49,7 @@ func TestStartCreatesIsolatedLayout(t *testing.T) { {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "remain-on-exit-format", "Command finished - F12 Snake - F10 Shell"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "split-window", "-v", "-l", "16", "-t", "sidequest-abc123:0.0"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "select-pane", "-t", "sidequest-abc123:0.1", "-T", "SNAKE"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-hook", "-t", "sidequest-abc123", "window-resized"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "F12", "select-pane", "-t", ":.+"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "F10", "detach-client"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "F9", "if-shell", "-F", "#{==:#{@sidequest_boss_hidden},1}"}, @@ -102,6 +103,75 @@ func TestStartSeedsDetachedSessionWithCurrentTerminalSize(t *testing.T) { } } +func TestStartUsesDeterministicGamePaneHeight(t *testing.T) { + tests := []struct { + name string + rows int + want string + }{ + {name: "below-max", rows: 24, want: "18"}, + {name: "max", rows: 36, want: "30"}, + {name: "above-max", rows: 60, want: "30"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runner := &recordingRunner{} + layout := Layout{ + CommandRunner: runner, + TerminalSize: func() (int, int, error) { + return 120, test.rows, nil + }, + } + runtimeSession := session.Session{ID: "height-" + test.name, SocketPath: "/tmp/sidequest-1000/height/command.sock"} + + if _, err := layout.Start( + runtimeSession, + []string{"/usr/bin/sidequest", "__sidequest-command-runner", runtimeSession.SocketPath}, + []string{"/usr/bin/sidequest", "__sidequest-game", "/tmp/sidequest-1000/height/state.json"}, + ); err != nil { + t.Fatalf("Start returned error: %v", err) + } + + joined := runner.joinedCalls() + want := "split-window -v -l " + test.want + if !strings.Contains(joined, want) { + t.Fatalf("split height missing %q:\n%s", want, joined) + } + }) + } +} + +func TestStartHooksWindowResizeToGamePaneTarget(t *testing.T) { + runner := &recordingRunner{} + layout := Layout{CommandRunner: runner} + runtimeSession := session.Session{ID: "resize", SocketPath: "/tmp/sidequest-1000/resize/command.sock"} + + if _, err := layout.Start( + runtimeSession, + []string{"/usr/bin/sidequest", "__sidequest-command-runner", runtimeSession.SocketPath}, + []string{"/usr/bin/sidequest", "__sidequest-game", "/tmp/sidequest-1000/resize/state.json"}, + ); err != nil { + t.Fatalf("Start returned error: %v", err) + } + + joined := runner.joinedCalls() + for _, want := range []string{ + "set-hook -t sidequest-resize window-resized", + "run-shell -b", + "h=#{window_height}", + "z=#{window_zoomed_flag}", + "target=30", + "max_for_game=$((h - 6))", + "resize-pane -t", + "sidequest-resize:0.1", + } { + if !strings.Contains(joined, want) { + t.Fatalf("resize hook missing %q:\n%s", want, joined) + } + } +} + func TestStartConfiguresEnhancedPaneFocusFormatting(t *testing.T) { runner := &recordingRunner{} layout := Layout{CommandRunner: runner}