clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

feat: stabilize help row layout across games

Dami committed 48bc9c7
commit
48bc9c709b2eb5656e35f4e0189f66b4a5453bee
parent
51ba889c080dbea07cf5d909c921312bc1301f60
author
Dami <dami.etoile@gmail.com>
19 files changed, +412 -49
Mgame/game_test.go74+++++++++++++++++++
Mgame/style.go114++++++++++++++++++++++++++++++
Mhashiwokakero/Model.go19++---
Mhitori/Model.go3+-
Mlightsout/Model.go3+-
Mnonogram/Model.go3+-
Mnonogram/nonogram_test.go30++++++++
Mnurikabe/Model.go25++++---
Mnurikabe/nurikabe_test.go94++++++++++++++++++++++---
Mnurikabe/style.go2+-
Mshikaku/Model.go14+++-
Mshikaku/shikaku_test.go39++++++++++
Mshikaku/style.go2+-
Msudoku/Model.go3+-
Mtakuzu/Model.go5+-
Mtheme/palette.go4+-
Mwordsearch/Model.go1+
Mwordsearch/style.go2+-
Mwordsearch/wordsearch_test.go24++++++
M game/game_test.go +74 -0
@@ -5,6 +5,7 @@ import (
55 "testing"66 77 tea "charm.land/bubbletea/v2"8+ "charm.land/lipgloss/v2"89 )910 1011 // --- Cursor.Move (P0) ---
@@ -300,6 +301,79 @@ func TestTitleBarView(t *testing.T) {
300301 })301302 }302303 304+// --- ComposeGameView (P1) ---305+306+func TestComposeGameView(t *testing.T) {307+ t.Run("secondary width is fixed at 150 percent of primary", func(t *testing.T) {308+ primary := lipgloss.NewStyle().Width(12).Render("grid")309+ view := ComposeGameView(310+ "title-width",311+ primary,312+ "this secondary row is much wider than the primary area",313+ )314+315+ if got, want := lipgloss.Width(view), 18; got != want {316+ t.Errorf("width = %d, want %d", got, want)317+ }318+ })319+320+ t.Run("long secondary wraps and increases height", func(t *testing.T) {321+ primary := lipgloss.NewStyle().Width(10).Render("grid")322+323+ short := ComposeGameView("title-wrap", primary, "short row")324+ long := ComposeGameView(325+ "title-wrap",326+ primary,327+ "this secondary row should wrap to multiple lines when constrained",328+ )329+330+ if got, want := lipgloss.Width(long), 15; got != want {331+ t.Errorf("width = %d, want %d", got, want)332+ }333+ if got, want := lipgloss.Width(short), 15; got != want {334+ t.Errorf("width = %d, want %d", got, want)335+ }336+ if lipgloss.Height(long) <= lipgloss.Height(short) {337+ t.Errorf("height = %d, want > %d", lipgloss.Height(long), lipgloss.Height(short))338+ }339+ })340+341+ t.Run("wrap prefers double-space boundaries", func(t *testing.T) {342+ row := "alpha: one beta: two gamma: three"343+ got := wrapRowOnDoubleSpace(row, 18)344+ want := "alpha: one\nbeta: two\ngamma: three"345+ if got != want {346+ t.Fatalf("wrapRowOnDoubleSpace() = %q, want %q", got, want)347+ }348+ })349+350+ t.Run("stable row reserves max variant height", func(t *testing.T) {351+ primary := lipgloss.NewStyle().Width(10).Render("grid")352+ longCurrent := ComposeGameViewRows(353+ "title-pad",354+ primary,355+ StableRow(356+ "alpha: one beta: two gamma: three",357+ "alpha: one",358+ "alpha: one beta: two gamma: three",359+ ),360+ )361+ shortCurrent := ComposeGameViewRows(362+ "title-pad",363+ primary,364+ StableRow(365+ "alpha: one",366+ "alpha: one",367+ "alpha: one beta: two gamma: three",368+ ),369+ )370+371+ if got, want := lipgloss.Height(shortCurrent), lipgloss.Height(longCurrent); got != want {372+ t.Fatalf("height(short) = %d, want %d", got, want)373+ }374+ })375+}376+303377 // --- DebugHeader (P3) ---304378 305379 func TestDebugHeader(t *testing.T) {
M game/style.go +114 -0
@@ -72,6 +72,120 @@ func TitleBarView(gameName, modeName string, solved bool) string {
7272 return lipgloss.NewStyle().PaddingBottom(1).Render(title)7373 }7474 75+// ComposeGameView joins title + primary content + optional secondary rows.76+// Secondary rows are width-anchored to primary so long help/info text wraps77+// instead of widening the full layout and shifting centered views.78+func ComposeGameView(title, primary string, secondary ...string) string {79+ rows := make([]SecondaryRow, 0, len(secondary))80+ for _, row := range secondary {81+ rows = append(rows, StaticRow(row))82+ }83+ return ComposeGameViewRows(title, primary, rows...)84+}85+86+// SecondaryRow defines one auxiliary row below the main game grid.87+// Variants can be provided to reserve the tallest wrapped height88+// up-front and avoid layout shift when row content changes.89+type SecondaryRow struct {90+ Current string91+ Variants []string92+}93+94+func StaticRow(row string) SecondaryRow {95+ return SecondaryRow{Current: row}96+}97+98+func StableRow(current string, variants ...string) SecondaryRow {99+ return SecondaryRow{Current: current, Variants: variants}100+}101+102+// ComposeGameViewRows joins title + primary content + optional secondary rows.103+// Secondary rows are rendered at 150% of primary width to reduce wrapping.104+// If variants are provided for a row, the row reserves the max wrapped height105+// across the variants and current value to avoid height jitter.106+func ComposeGameViewRows(title, primary string, rows ...SecondaryRow) string {107+ sections := []string{title, primary}108+ primaryWidth := lipgloss.Width(primary)109+ if primaryWidth <= 0 {110+ for _, row := range rows {111+ sections = append(sections, row.Current)112+ }113+ return lipgloss.JoinVertical(lipgloss.Center, sections...)114+ }115+116+ secondaryWidth := primaryWidth + (primaryWidth / 2)117+ rowStyle := lipgloss.NewStyle().Width(secondaryWidth).AlignHorizontal(lipgloss.Center)118+ for _, row := range rows {119+ rendered := wrapAndPadSecondary(row, secondaryWidth)120+ sections = append(sections, rowStyle.Render(rendered))121+ }122+123+ return lipgloss.JoinVertical(lipgloss.Center, sections...)124+}125+126+func wrapAndPadSecondary(row SecondaryRow, width int) string {127+ current := wrapRowOnDoubleSpace(row.Current, width)128+ target := lipgloss.Height(current)129+ for _, variant := range row.Variants {130+ h := lipgloss.Height(wrapRowOnDoubleSpace(variant, width))131+ if h > target {132+ target = h133+ }134+ }135+ if h := lipgloss.Height(current); target > h {136+ current += strings.Repeat("\n", target-h)137+ }138+ return current139+}140+141+func wrapRowOnDoubleSpace(row string, width int) string {142+ if width <= 0 || !strings.Contains(row, " ") {143+ return row144+ }145+146+ lines := strings.Split(row, "\n")147+ var out []string148+ for _, line := range lines {149+ out = append(out, wrapLineOnDoubleSpace(line, width)...)150+ }151+ return strings.Join(out, "\n")152+}153+154+func wrapLineOnDoubleSpace(line string, width int) []string {155+ if width <= 0 || lipgloss.Width(line) <= width || !strings.Contains(line, " ") {156+ return []string{line}157+ }158+159+ parts := strings.Split(line, " ")160+ var (161+ wrapped []string162+ current string163+ )164+ for _, part := range parts {165+ if part == "" {166+ continue167+ }168+ if current == "" {169+ current = part170+ continue171+ }172+ candidate := current + " " + part173+ if lipgloss.Width(candidate) <= width {174+ current = candidate175+ continue176+ }177+ wrapped = append(wrapped, current)178+ current = part179+ }180+ if current != "" {181+ wrapped = append(wrapped, current)182+ }183+ if len(wrapped) == 0 {184+ return []string{line}185+ }186+ return wrapped187+}188+75189 // DebugHeader returns the markdown heading and property table header for debug info.76190 // rows is a list of [key, value] pairs for the "Game State" table.77191 func DebugHeader(title string, rows [][2]string) string {
M hashiwokakero/Model.go +9 -10
M hitori/Model.go +1 -2
M lightsout/Model.go +1 -2
M nonogram/Model.go +2 -1
M nonogram/nonogram_test.go +30 -0
M nurikabe/Model.go +15 -10
@@ -5,7 +5,6 @@ import (
55 66 "charm.land/bubbles/v2/key"77 tea "charm.land/bubbletea/v2"8- "charm.land/lipgloss/v2"98 "github.com/FelineStateMachine/puzzletea/game"109 )1110
@@ -77,6 +76,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
7776 switch msg := msg.(type) {7877 case game.HelpToggleMsg:7978 m.showFullHelp = msg.Show79+ m.originValid = false8080 8181 case tea.WindowSizeMsg:8282 m.termWidth = msg.Width
@@ -91,7 +91,8 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
9191 }9292 case key.Matches(msg, m.keys.SetIsland):9393 if !m.solved {94- m.setCellAtCursor(islandCell)94+ current := m.marks[m.cursor.Y][m.cursor.X]95+ m.setCellAtCursor(m.islandTarget(current))9596 }9697 case key.Matches(msg, m.keys.Clear):9798 if !m.solved {
@@ -113,12 +114,12 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
113114 m.cursor.X, m.cursor.Y = col, row114115 switch msg.Button {115116 case tea.MouseLeft:116- target := m.leftClickTarget(m.marks[row][col])117+ target := m.leftClickTarget()117118 m.dragging = true118119 m.dragTarget = target119120 m.setCellAtCursor(target)120121 case tea.MouseRight:121- target := m.rightClickTarget()122+ target := m.rightClickTarget(m.marks[row][col])122123 m.dragging = true123124 m.dragTarget = target124125 m.setCellAtCursor(target)
@@ -171,14 +172,18 @@ func (m *Model) recomputeDerivedState() {
171172 m.conflicts = computeConflicts(m.marks, m.clues)172173 }173174 174-func (m Model) leftClickTarget(current cellState) cellState {175- if current == seaCell {176- return unknownCell177- }175+func (m Model) leftClickTarget() cellState {178176 return seaCell179177 }180178 181-func (m Model) rightClickTarget() cellState {179+func (m Model) rightClickTarget(current cellState) cellState {180+ return m.islandTarget(current)181+}182+183+func (m Model) islandTarget(current cellState) cellState {184+ if current == islandCell {185+ return unknownCell186+ }182187 return islandCell183188 }184189
@@ -186,7 +191,7 @@ func (m Model) View() string {
186191 title := game.TitleBarView("Nurikabe", m.modeTitle, m.solved)187192 grid := gridView(m)188193 status := statusBarView(m.showFullHelp)189- return lipgloss.JoinVertical(lipgloss.Center, title, grid, status)194+ return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))190195 }191196 192197 func (m Model) SetTitle(t string) game.Gamer {
M nurikabe/nurikabe_test.go +84 -10
@@ -279,6 +279,12 @@ func TestModelKeyboardUpdate(t *testing.T) {
279279 if m.marks[1][1] != islandCell {280280 t.Fatalf("expected island at (1,1), got %q", rune(m.marks[1][1]))281281 }282+283+ next, _ = m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})284+ m = next.(Model)285+ if m.marks[1][1] != unknownCell {286+ t.Fatalf("expected unknown at (1,1), got %q", rune(m.marks[1][1]))287+ }282288 }283289 284290 func TestModelMouseDragPaint(t *testing.T) {
@@ -314,7 +320,7 @@ func TestModelMouseDragPaint(t *testing.T) {
314320 }315321 }316322 317-func TestModelMouseLeftClickToggleBehavior(t *testing.T) {323+func TestModelMouseClickMirrorsKeyboard(t *testing.T) {318324 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)319325 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(320326 []int{2, 0, 0},
@@ -342,8 +348,8 @@ func TestModelMouseLeftClickToggleBehavior(t *testing.T) {
342348 343349 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})344350 m = next.(Model)345- if m.marks[1][1] != unknownCell {346- t.Fatalf("left click from sea should set unknown, got %q", rune(m.marks[1][1]))351+ if m.marks[1][1] != seaCell {352+ t.Fatalf("left click from sea should keep sea, got %q", rune(m.marks[1][1]))347353 }348354 349355 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})
@@ -352,14 +358,20 @@ func TestModelMouseLeftClickToggleBehavior(t *testing.T) {
352358 t.Fatalf("right click should set island, got %q", rune(m.marks[1][1]))353359 }354360 361+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})362+ m = next.(Model)363+ if m.marks[1][1] != unknownCell {364+ t.Fatalf("right click from island should set unknown, got %q", rune(m.marks[1][1]))365+ }366+355367 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})356368 m = next.(Model)357369 if m.marks[1][1] != seaCell {358- t.Fatalf("left click from island should set sea, got %q", rune(m.marks[1][1]))370+ t.Fatalf("left click from unknown should set sea, got %q", rune(m.marks[1][1]))359371 }360372 }361373 362-func TestModelMouseRightClickSetsIsland(t *testing.T) {374+func TestModelMouseRightClickTogglesIsland(t *testing.T) {363375 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)364376 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(365377 []int{2, 0, 0},
@@ -387,8 +399,8 @@ func TestModelMouseRightClickSetsIsland(t *testing.T) {
387399 388400 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})389401 m = next.(Model)390- if m.marks[1][1] != islandCell {391- t.Fatalf("expected island after second right click, got %q", rune(m.marks[1][1]))402+ if m.marks[1][1] != unknownCell {403+ t.Fatalf("expected unknown after second right click, got %q", rune(m.marks[1][1]))392404 }393405 }394406
@@ -423,7 +435,42 @@ func TestModelMouseRightDragPaintIsland(t *testing.T) {
423435 }424436 }425437 426-func TestModelMouseLeftDragPaintUnknownFromSea(t *testing.T) {438+func TestModelMouseRightDragClearsIsland(t *testing.T) {439+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)440+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(441+ []int{2, 0, 0},442+ []int{0, 0, 0},443+ []int{0, 0, 1},444+ )}445+ g, err := New(mode, p)446+ if err != nil {447+ t.Fatalf("New error: %v", err)448+ }449+ m := g.(Model)450+ m.cursor = pointToCursor(1, 1)451+ m.setCellAtCursor(islandCell)452+ m.cursor = pointToCursor(2, 1)453+ m.setCellAtCursor(islandCell)454+455+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})456+ m = next.(Model)457+ ox, oy := m.gridOrigin()458+459+ next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseRight})460+ m = next.(Model)461+462+ next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseRight})463+ m = next.(Model)464+465+ next, _ = m.Update(tea.MouseReleaseMsg{})466+ m = next.(Model)467+468+ if m.marks[1][1] != unknownCell || m.marks[1][2] != unknownCell {469+ t.Fatalf("right drag from island should clear to unknown: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))470+ }471+}472+473+func TestModelMouseLeftDragPaintSeaFromSea(t *testing.T) {427474 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)428475 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(429476 []int{2, 0, 0},
@@ -451,8 +498,35 @@ func TestModelMouseLeftDragPaintUnknownFromSea(t *testing.T) {
451498 next, _ = m.Update(tea.MouseReleaseMsg{})452499 m = next.(Model)453500 454- if m.marks[1][1] != unknownCell || m.marks[1][2] != unknownCell {455- t.Fatalf("left drag from sea should paint unknown: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))501+ if m.marks[1][1] != seaCell || m.marks[1][2] != seaCell {502+ t.Fatalf("left drag from sea should paint sea: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))503+ }504+}505+506+func TestHelpToggleInvalidatesOriginCache(t *testing.T) {507+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)508+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(509+ []int{2, 0, 0},510+ []int{0, 0, 0},511+ []int{0, 0, 1},512+ )}513+ g, err := New(mode, p)514+ if err != nil {515+ t.Fatalf("New error: %v", err)516+ }517+ m := g.(Model)518+ m.originValid = true519+ m.originX = 5520+ m.originY = 7521+522+ next, _ := m.Update(game.HelpToggleMsg{Show: true})523+ got := next.(Model)524+525+ if !got.showFullHelp {526+ t.Fatal("expected showFullHelp to be true")527+ }528+ if got.originValid {529+ t.Fatal("expected origin cache to be invalidated")456530 }457531 }458532
M nurikabe/style.go +1 -1
M shikaku/Model.go +10 -4
M shikaku/shikaku_test.go +39 -0
M shikaku/style.go +1 -1
M sudoku/Model.go +1 -2
M takuzu/Model.go +2 -3
M theme/palette.go +2 -2
M wordsearch/Model.go +1 -0
M wordsearch/style.go +1 -1
M wordsearch/wordsearch_test.go +24 -0