Skip to content

Commit fc2ff95

Browse files
HerbHallclaude
andauthored
fix: compact menu layout, transition animation, and game UI fixes (#48)
- Replace 4-line ASCII title with single-line "CLI PLAY" to save vertical space - Remove inter-category blank lines and reduce padding for tighter layout - Create TransitionText() method mirroring View() logic as plain text - Strip emoji from transition text to fix width mismatches causing artifacts - Add tea.ClearScreen when switching from transition to menu - Fix Skills category icon (variation selector emoji causing stray border bar) - Add Q quit option to Blackjack during player turn phase - Fix tic-tac-toe grid separator width to align with cell rows Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c8484d commit fc2ff95

5 files changed

Lines changed: 143 additions & 59 deletions

File tree

internal/app/model.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100100
if m.active == screenSplash {
101101
// Build the text that the transition will dissolve and reveal.
102102
splashText := splash.TitleArt + "\n\n" + splash.Credits
103-
menuText := menu.MenuText(m.width, m.height)
103+
menuText := m.menu.TransitionText()
104104
m.transition = transition.New(m.width, m.height, splashText, menuText)
105105
m.active = screenTransition
106106
return m, m.transition.Init()
@@ -119,7 +119,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119119
m.transition, cmd = m.transition.Update(msg)
120120
if m.transition.Done() {
121121
m.active = screenMenu
122-
return m, m.menu.Init()
122+
return m, tea.Batch(tea.ClearScreen, m.menu.Init())
123123
}
124124
return m, cmd
125125

internal/blackjack/model.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ func (m Model) updatePlayerTurn(key string) (tea.Model, tea.Cmd) {
120120
if m.game.CanDoubleDown() {
121121
m.game.DoubleDown() //nolint:errcheck // CanDoubleDown pre-validates
122122
}
123+
case "q":
124+
m.done = true
123125
}
124126
return m, nil
125127
}
@@ -269,6 +271,7 @@ func (m Model) renderHelp() string {
269271
if m.game.CanDoubleDown() {
270272
help += " [D] Double Down"
271273
}
274+
help += " [Q] Quit"
272275
return helpStyle.Render(help)
273276
case PhaseResult:
274277
return helpStyle.Render("[Enter/N] New Round [Q] Quit")

internal/menu/data.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ var categories = []category{
3333
{Name: "Puzzles", Icon: "\U0001f9e9", Indices: []int{2, 3, 4, 5, 11, 8}}, // Wordle, Minesweeper, Sudoku, 2048, Fifteen Puzzle, Mastermind
3434
{Name: "Action", Icon: "\U0001f3ae", Indices: []int{12, 13}}, // Snake, Tetris
3535
{Name: "Strategy", Icon: "\U0001f0cf", Indices: []int{6, 7, 10, 9}}, // Hangman, Tic-Tac-Toe, Connect Four, Memory
36-
{Name: "Skills", Icon: "\u2328\ufe0f", Indices: []int{15}}, // Typing Test
36+
{Name: "Skills", Icon: "\U0001f3af", Indices: []int{15}}, // Typing Test (dart/target -- consistent 2-cell emoji)
3737
}
3838

3939
// gamePreview holds the info panel text for each game.

internal/menu/model.go

Lines changed: 136 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -438,13 +438,8 @@ var (
438438
Foreground(lipgloss.Color("#00FF87"))
439439
)
440440

441-
// asciiTitle is a styled "CLI PLAY" header.
442-
var asciiTitle = strings.Join([]string{
443-
" ___ _ ___ ___ _ _ __ __",
444-
" / __|| | |_ _| | _ \\| | /_\\ \\ \\ / /",
445-
"| (__ | |__ | | | _/| |__ / _ \\ \\ V / ",
446-
" \\___||____||___| |_| |____|/_/ \\_\\ |_| ",
447-
}, "\n")
441+
// compactTitle is a single-line styled title for the menu header.
442+
const compactTitle = "CLI PLAY"
448443

449444
// colWidth is the fixed visual width of a single game entry in multi-column mode.
450445
// cursor(3) + shortcut(4) + icon(3) + name(16) + gap(2) = 28.
@@ -469,20 +464,17 @@ func (m Model) contentHeight(showTitle, showStats, showPreview, showTip bool) in
469464
cols := m.columnCount()
470465
lines := 0
471466
if showTitle {
472-
lines += 5 // 4 ASCII art lines + blank
467+
lines++ // compact single-line title
473468
}
474469
if showStats {
475-
lines += 2 // stats + blank
470+
lines++ // stats line
476471
}
477-
// Game list: category headers + game rows in grid + inter-category blanks.
478-
for i, cat := range categories {
479-
if i > 0 {
480-
lines++ // blank between categories
481-
}
472+
// Game list: category headers + game rows in grid (no inter-category blanks).
473+
for _, cat := range categories {
482474
lines++ // header
483475
lines += (len(cat.Indices) + cols - 1) / cols // game rows
484476
}
485-
lines += 2 // blank + settings row
477+
lines++ // settings row
486478
if showPreview {
487479
lines += 8 // border+title+rules+controls ~ 7-8 lines
488480
}
@@ -497,8 +489,8 @@ func (m Model) contentHeight(showTitle, showStats, showPreview, showTip bool) in
497489
func (m Model) View() string {
498490
var b strings.Builder
499491

500-
// Available inner height: total minus border (2) and padding (2).
501-
innerH := m.height - 4
492+
// Available inner height: total minus border (2).
493+
innerH := m.height - 2
502494

503495
// Progressively hide elements to fit: preview first, then title, then stats, then tips.
504496
showTitle := true
@@ -519,10 +511,10 @@ func (m Model) View() string {
519511
showTip = false
520512
}
521513

522-
// Title (#22).
514+
// Compact title on a single line (#22).
523515
if showTitle {
524-
b.WriteString(titleStyle.Render(asciiTitle))
525-
b.WriteString("\n\n")
516+
b.WriteString(titleStyle.Render(compactTitle))
517+
b.WriteString("\n")
526518
}
527519

528520
// Session stats bar (#26).
@@ -533,13 +525,13 @@ func (m Model) View() string {
533525
}
534526
statsLine := fmt.Sprintf("Games played: %d | Session: %dm", m.gamesPlayed, elapsed)
535527
b.WriteString(statsStyle.Render(statsLine))
536-
b.WriteString("\n\n")
528+
b.WriteString("\n")
537529
}
538530

539531
// Welcome back flash (#27).
540532
if m.showWelcome {
541533
b.WriteString(welcomeStyle.Render(" Welcome back!"))
542-
b.WriteString("\n\n")
534+
b.WriteString("\n")
543535
}
544536

545537
// Game list with categories (#30), icons (#24), shortcuts (#23).
@@ -553,13 +545,7 @@ func (m Model) View() string {
553545
break
554546
}
555547

556-
// Inter-category spacing.
557-
if catI > 0 {
558-
b.WriteString("\n")
559-
visualRow++
560-
}
561-
562-
// Category header.
548+
// Category header (no inter-category blank lines to save vertical space).
563549
cat := categories[catI]
564550
b.WriteString(categoryStyle.Render(fmt.Sprintf(" %s %s", cat.Icon, cat.Name)))
565551
b.WriteString("\n")
@@ -594,7 +580,6 @@ func (m Model) View() string {
594580
}
595581

596582
// Settings entry.
597-
b.WriteString("\n")
598583
for ri, row := range menuRows {
599584
if row.gameIndex == SettingsIndex {
600585
b.WriteString(m.renderEntry(row, ri, compact))
@@ -627,11 +612,7 @@ func (m Model) View() string {
627612
panel := lipgloss.NewStyle().
628613
Border(panelBorder).
629614
BorderForeground(lipgloss.Color("240")).
630-
Padding(1, 2).
631-
BorderTop(true).
632-
BorderBottom(true).
633-
BorderLeft(true).
634-
BorderRight(true).
615+
Padding(0, 2).
635616
Render(b.String())
636617

637618
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, panel)
@@ -665,39 +646,139 @@ func (m Model) renderPreview() string {
665646
return previewBorder.Render(pb.String())
666647
}
667648

668-
// MenuText returns the menu layout as plain text (no ANSI styling).
669-
// The transition model uses this to pre-compute character positions
670-
// for the reveal animation.
671-
func MenuText(width, height int) string {
649+
650+
// TransitionText returns the menu layout as plain text (no ANSI, no border)
651+
// for the transition reveal animation. It mirrors the View() content using
652+
// the same responsive logic (column count, show/hide flags) so the revealed
653+
// text matches the real menu layout. The caller (transition) centers it.
654+
func (m Model) TransitionText() string {
672655
var b strings.Builder
673656

674-
b.WriteString(asciiTitle)
675-
b.WriteString("\n\n")
657+
innerH := m.height - 2
658+
showTitle := true
659+
showStats := true
660+
showPreview := true
661+
showTip := true
676662

677-
displayIdx := 0
678-
for catIdx := range categories {
679-
cat := categories[catIdx]
680-
if catIdx > 0 {
681-
b.WriteString("\n")
682-
}
683-
b.WriteString(fmt.Sprintf(" %s %s\n", cat.Icon, cat.Name))
663+
if m.contentHeight(showTitle, showStats, showPreview, showTip) > innerH {
664+
showPreview = false
665+
}
666+
if m.contentHeight(showTitle, showStats, showPreview, showTip) > innerH {
667+
showTitle = false
668+
}
669+
if m.contentHeight(showTitle, showStats, showPreview, showTip) > innerH {
670+
showStats = false
671+
}
672+
if m.contentHeight(showTitle, showStats, showPreview, showTip) > innerH {
673+
showTip = false
674+
}
675+
676+
if showTitle {
677+
b.WriteString(compactTitle)
678+
b.WriteString("\n")
679+
}
680+
if showStats {
681+
b.WriteString(fmt.Sprintf("Games played: %d | Session: 0m", m.gamesPlayed))
682+
b.WriteString("\n")
683+
}
684+
685+
cols := m.columnCount()
686+
compact := cols > 1
687+
688+
for catI := range categories {
689+
cat := categories[catI]
690+
b.WriteString(fmt.Sprintf(" %s\n", cat.Name))
691+
692+
// Collect game rows for this category.
693+
var catGames []int
684694
for _, gi := range cat.Indices {
685-
label := shortcutLabel(displayIdx)
686-
icon := gameIcon[gi]
687-
b.WriteString(fmt.Sprintf(" [%s] %-3s%-16s%s\n", label, icon, Games[gi].Name, Games[gi].Description))
688-
displayIdx++
695+
for ri, row := range menuRows {
696+
if row.gameIndex == gi {
697+
catGames = append(catGames, ri)
698+
break
699+
}
700+
}
701+
}
702+
703+
// Render in grid.
704+
for i := 0; i < len(catGames); i += cols {
705+
for j := 0; j < cols && i+j < len(catGames); j++ {
706+
ri := catGames[i+j]
707+
row := menuRows[ri]
708+
b.WriteString(plainEntry(row, ri == m.cursor, compact))
709+
if compact && j < cols-1 && i+j+1 < len(catGames) {
710+
b.WriteString(" ")
711+
}
712+
}
713+
b.WriteString("\n")
689714
}
690715
}
691716

692-
b.WriteString("\n")
693-
b.WriteString(fmt.Sprintf(" \u2699 %-16s%s\n", "Settings", "Preferences and configuration"))
717+
// Settings.
718+
for ri, row := range menuRows {
719+
if row.gameIndex == SettingsIndex {
720+
b.WriteString(plainEntry(row, ri == m.cursor, compact))
721+
b.WriteString("\n")
722+
break
723+
}
724+
}
694725

695726
b.WriteString("\n")
696-
b.WriteString(" \u2191\u2193 Navigate | Enter Select | 1-9/0/a-f Quick Select | Q Quit")
727+
if showTip && m.tipIndex < len(tips) {
728+
b.WriteString(tips[m.tipIndex])
729+
b.WriteString("\n")
730+
}
731+
b.WriteString(" \u2190\u2191\u2193\u2192 Navigate | Enter Select | 1-9/0/a-f Quick Select | Q Quit")
697732

698733
return b.String()
699734
}
700735

736+
// plainEntry renders a single menu entry as plain text (no ANSI).
737+
func plainEntry(row menuRow, selected, compact bool) string {
738+
var e strings.Builder
739+
740+
// Cursor.
741+
if selected {
742+
e.WriteString(" \u25b6 ")
743+
} else {
744+
e.WriteString(" ")
745+
}
746+
747+
// Shortcut.
748+
if row.gameIndex < SettingsIndex && row.displayIndex >= 0 {
749+
e.WriteString(fmt.Sprintf("[%s] ", shortcutLabel(row.displayIndex)))
750+
} else {
751+
e.WriteString(" ")
752+
}
753+
754+
// Icon.
755+
if icon, ok := gameIcon[row.gameIndex]; ok {
756+
e.WriteString(fmt.Sprintf("%-3s", icon))
757+
} else if row.gameIndex == SettingsIndex {
758+
e.WriteString("\u2699 ")
759+
}
760+
761+
// Name.
762+
name := ""
763+
if row.gameIndex == SettingsIndex {
764+
name = "Settings"
765+
} else if row.gameIndex >= 0 && row.gameIndex < len(Games) {
766+
name = Games[row.gameIndex].Name
767+
}
768+
e.WriteString(fmt.Sprintf("%-16s", name))
769+
770+
// Description only in single-column.
771+
if !compact {
772+
if row.gameIndex == SettingsIndex {
773+
e.WriteString("Preferences and configuration")
774+
} else if row.gameIndex >= 0 && row.gameIndex < len(Games) {
775+
e.WriteString(Games[row.gameIndex].Description)
776+
}
777+
}
778+
779+
return e.String()
780+
}
781+
701782
// Selected returns the index of the selected game, or -1 if none.
702783
func (m Model) Selected() int {
703784
return m.selected

internal/tictactoe/model.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ func (m Model) renderBoard() string {
212212
rows = append(rows, strings.Join(cells, gridStyle.Render(" │ ")))
213213

214214
if r < 2 {
215-
rows = append(rows, gridStyle.Render("─────────"))
215+
rows = append(rows, gridStyle.Render("─────────"))
216216
}
217217
}
218218

0 commit comments

Comments
 (0)