clone

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

update menus

Dami committed aff3bff
commit
aff3bfff3c42597e95f3c0d4be60aa419e63ef7b
parent
20e6a1c35731f6f922eb3b49932c97e519018615
author
Dami <dami.etoile@gmail.com>
9 files changed, +259 -51
Mgo.sum2-
Mmodel.go22++++----
Mui/list.go15++---
Aui/mainmenu.go86++++++++++++++++++++++++++++++
Aui/panel.go27+++++++++
Mui/style.go50+++++++++++++++++
Mui/table.go31++++++++++-
Mupdate.go18++++--
Mview.go59++++++++++++++-------
M go.sum +0 -2
M model.go +11 -11
@@ -36,12 +36,12 @@ var (
3636 game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes, Help: wordsearch.HelpContent},3737 }3838 39- mainMenuItems = []list.Item{40- ui.MenuItem{ItemTitle: "Daily Puzzle", Desc: time.Now().Format("Jan _2 06")},41- ui.MenuItem{ItemTitle: "Generate", Desc: "a new puzzle."},42- ui.MenuItem{ItemTitle: "Continue", Desc: "a previous puzzle."},43- ui.MenuItem{ItemTitle: "Guides", Desc: "to learn."},44- ui.MenuItem{ItemTitle: "Quit", Desc: "the game."},39+ mainMenuItems = []ui.MenuItem{40+ {ItemTitle: "Daily Puzzle", Desc: time.Now().Format("Jan _2 06")},41+ {ItemTitle: "Generate", Desc: "a new puzzle"},42+ {ItemTitle: "Continue", Desc: "a saved puzzle"},43+ {ItemTitle: "Guides", Desc: "learn the rules"},44+ {ItemTitle: "Quit", Desc: "exit puzzletea"},4545 }4646 )4747
@@ -59,7 +59,7 @@ const (
5959 type model struct {6060 state int6161 62- mainMenuList list.Model62+ mainMenu ui.MainMenu6363 gameSelectList list.Model6464 modeSelectList list.Model6565 selectedCategory game.Category
@@ -102,7 +102,7 @@ type model struct {
102102 func initialModel(s *store.Store) model {103103 r := initDebugRenderer()104104 l := ui.InitList(GameCategories, "Select Category")105- ml := ui.InitList(mainMenuItems, "puzzletea")105+ mm := ui.NewMainMenu(mainMenuItems)106106 sp := spinner.New()107107 sp.Spinner = spinner.Dot108108 sp.Style = lipgloss.NewStyle().Foreground(ui.MenuAccent)
@@ -110,7 +110,7 @@ func initialModel(s *store.Store) model {
110110 state: mainMenuView,111111 debugRenderer: r,112112 gameSelectList: l,113- mainMenuList: ml,113+ mainMenu: mm,114114 spinner: sp,115115 store: s,116116 }
@@ -121,7 +121,7 @@ func initialModel(s *store.Store) model {
121121 func initialModelWithGame(s *store.Store, g game.Gamer, activeGameID int64, completionSaved bool) model {122122 r := initDebugRenderer()123123 l := ui.InitList(GameCategories, "Select Category")124- ml := ui.InitList(mainMenuItems, "puzzletea")124+ mm := ui.NewMainMenu(mainMenuItems)125125 sp := spinner.New()126126 sp.Spinner = spinner.Dot127127 sp.Style = lipgloss.NewStyle().Foreground(ui.MenuAccent)
@@ -129,7 +129,7 @@ func initialModelWithGame(s *store.Store, g game.Gamer, activeGameID int64, comp
129129 state: gameView,130130 debugRenderer: r,131131 gameSelectList: l,132- mainMenuList: ml,132+ mainMenu: mm,133133 spinner: sp,134134 store: s,135135 game: g,
M ui/list.go +5 -10
A ui/mainmenu.go +86 -0
@@ -0,0 +1,86 @@
1+package ui2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+)8+9+// logo is the pre-rendered "puzzletea" ASCII art (FIGlet "small" font).10+// Stored as a raw string to avoid a runtime dependency on go-figure.11+const logo = "" +12+ " _ _\n" +13+ " _ __ _ _ ___ ___ | | ___ | |_ ___ __ _\n" +14+ " | '_ \\ | || | |_ / |_ / | | / -_) | _| / -_) / _` |\n" +15+ " | .__/ \\_,_| /__| /__| |_| \\___| \\__| \\___| \\__,_|\n" +16+ " |_|"17+18+// MainMenu is a custom main menu component with an ASCII art logo19+// and styled item list. It replaces the generic list.Model for the20+// root menu to give the application a game-themed identity.21+type MainMenu struct {22+ items []MenuItem23+ cursor int24+}25+26+// NewMainMenu creates a main menu with an ASCII art logo and the given items.27+func NewMainMenu(items []MenuItem) MainMenu {28+ return MainMenu{29+ items: items,30+ }31+}32+33+// CursorUp moves the selection cursor up, wrapping around.34+func (m *MainMenu) CursorUp() {35+ m.cursor--36+ if m.cursor < 0 {37+ m.cursor = len(m.items) - 138+ }39+}40+41+// CursorDown moves the selection cursor down, wrapping around.42+func (m *MainMenu) CursorDown() {43+ m.cursor++44+ if m.cursor >= len(m.items) {45+ m.cursor = 046+ }47+}48+49+// Selected returns the currently highlighted menu item.50+func (m MainMenu) Selected() MenuItem {51+ return m.items[m.cursor]52+}53+54+// View renders the main menu as a framed panel with logo and items.55+func (m MainMenu) View() string {56+ styledLogo := LogoStyle.Render(logo)57+58+ var itemLines []string59+ for i, item := range m.items {60+ var line string61+ if i == m.cursor {62+ cursor := CursorStyle.Render("▸ ")63+ title := SelectedItemStyle.Render(item.ItemTitle)64+ desc := DimItemStyle.Render(" " + item.Desc)65+ line = cursor + title + desc66+ } else {67+ title := NormalItemStyle.Render(item.ItemTitle)68+ desc := DimItemStyle.Render(" " + item.Desc)69+ line = " " + title + desc70+ }71+ itemLines = append(itemLines, line)72+ }73+ items := strings.Join(itemLines, "\n")74+75+ footer := FooterHint.Render("↑/↓ navigate • enter select • ctrl+c quit")76+77+ inner := lipgloss.JoinVertical(lipgloss.Left,78+ styledLogo,79+ "",80+ items,81+ "",82+ footer,83+ )84+85+ return HeavyPanel(inner)86+}
A ui/panel.go +27 -0
M ui/style.go +50 -0
@@ -17,6 +17,56 @@ var (
1717 DebugStyle = lipgloss.NewStyle().1818 Border(lipgloss.RoundedBorder(), true).1919 BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("124"), Dark: lipgloss.Color("124")})20+21+ // LogoStyle renders the ASCII art brand logo.22+ LogoStyle = lipgloss.NewStyle().23+ Foreground(MenuAccent).24+ Bold(true)25+26+ // MainMenuFrame wraps the main menu in a heavy double border.27+ MainMenuFrame = lipgloss.NewStyle().28+ Border(lipgloss.DoubleBorder()).29+ BorderForeground(MenuAccent).30+ Padding(1, 3)31+32+ // PanelFrame wraps sub-menus in a lighter rounded border.33+ PanelFrame = lipgloss.NewStyle().34+ Border(lipgloss.RoundedBorder()).35+ BorderForeground(MenuDim).36+ Padding(1, 2)37+38+ // PanelTitle styles the title line inside a panel.39+ PanelTitle = lipgloss.NewStyle().40+ Bold(true).41+ Foreground(MenuAccent)42+43+ // FooterHint styles the navigation hint line at the bottom of panels.44+ FooterHint = lipgloss.NewStyle().45+ Foreground(MenuTextDim)46+47+ // CursorStyle styles the active cursor indicator.48+ CursorStyle = lipgloss.NewStyle().49+ Foreground(MenuAccent).50+ Bold(true)51+52+ // SelectedItemStyle styles the currently highlighted menu item title.53+ SelectedItemStyle = lipgloss.NewStyle().54+ Foreground(MenuAccent).55+ Bold(true)56+57+ // NormalItemStyle styles non-selected menu item titles.58+ NormalItemStyle = lipgloss.NewStyle().59+ Foreground(MenuText)60+61+ // DimItemStyle styles descriptions and secondary text.62+ DimItemStyle = lipgloss.NewStyle().63+ Foreground(MenuTextDim)64+65+ // GeneratingFrame wraps the generating spinner in a small box.66+ GeneratingFrame = lipgloss.NewStyle().67+ Border(lipgloss.RoundedBorder()).68+ BorderForeground(MenuAccent).69+ Padding(1, 3)2070 )2171 2272 // CenterView wraps content in centered placement within the available area.
M ui/table.go +30 -1
@@ -1,6 +1,7 @@
11 package ui22 33 import (4+ "fmt"45 "log"56 67 "charm.land/bubbles/v2/table"
@@ -9,6 +10,9 @@ import (
910 "github.com/FelineStateMachine/puzzletea/store"1011 )1112 13+// MaxTableRows is the maximum number of rows visible in the continue table.14+const MaxTableRows = 2015+1216 // FormatStatus converts a GameStatus enum to a human-readable display string.1317 func FormatStatus(s store.GameStatus) string {1418 switch s {
@@ -58,12 +62,13 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
5862 // Account for column gaps (2 chars between each column).5963 tableWidth += (len(columns) - 1) * 26064 65+ visibleRows := min(len(rows), MaxTableRows)6166 t := table.New(6267 table.WithColumns(columns),6368 table.WithRows(rows),6469 table.WithFocused(true),6570 table.WithWidth(tableWidth),66- table.WithHeight(max(height-2, 1)),71+ table.WithHeight(min(max(height-2, 1), visibleRows)),6772 )6873 6974 st := table.DefaultStyles()
@@ -83,3 +88,27 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
8388 8489 return t, games8590 }91+92+// TablePagination returns a pagination string like "1-20 of 45" for the93+// continue table, or an empty string if all rows fit on one page.94+func TablePagination(t table.Model) string {95+ total := len(t.Rows())96+ if total <= MaxTableRows {97+ return ""98+ }99+ cursor := t.Cursor()100+ height := t.Height()101+102+ // Estimate the visible window based on cursor and table height.103+ start := cursor - height + 1104+ if start < 0 {105+ start = 0106+ }107+ end := start + height108+ if end > total {109+ end = total110+ start = end - height111+ }112+113+ return FooterHint.Render(fmt.Sprintf("%d–%d of %d", start+1, end, total))114+}
M update.go +11 -7
@@ -26,14 +26,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2626 m.width = msg.Width2727 m.height = msg.Height2828 menuW := min(m.width, 64)29- m.mainMenuList.SetSize(menuW, min(m.height, ui.ListHeight(m.mainMenuList)))3029 m.gameSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.gameSelectList)))3130 if m.state == modeSelectView {3231 m.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.modeSelectList)))3332 }3433 if m.state == continueView {3534 m.continueTable.SetWidth(m.width)36- m.continueTable.SetHeight(m.height)35+ visibleRows := min(len(m.continueGames), ui.MaxTableRows)36+ m.continueTable.SetHeight(min(m.height, visibleRows))3737 }3838 if m.state == helpSelectView {3939 m.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.helpSelectList)))
@@ -90,7 +90,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9090 9191 switch m.state {9292 case mainMenuView:93- m.mainMenuList, cmd = m.mainMenuList.Update(msg)93+ if msg, ok := msg.(tea.KeyPressMsg); ok {94+ switch msg.String() {95+ case "up", "k":96+ m.mainMenu.CursorUp()97+ case "down", "j":98+ m.mainMenu.CursorDown()99+ }100+ }94101 case generatingView:95102 m.spinner, cmd = m.spinner.Update(msg)96103 case gameView:
@@ -138,10 +145,7 @@ func (m model) handleEnter() (tea.Model, tea.Cmd) {
138145 }139146 140147 func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {141- item, ok := m.mainMenuList.SelectedItem().(ui.MenuItem)142- if !ok {143- return m, nil144- }148+ item := m.mainMenu.Selected()145149 switch item.Title() {146150 case "Daily Puzzle":147151 return m.handleDailyPuzzle()
M view.go +39 -20
@@ -7,7 +7,6 @@ import (
77 88 tea "charm.land/bubbletea/v2"99 "charm.land/lipgloss/v2"10- "charm.land/lipgloss/v2/compat"1110 )1211 1312 func (m model) View() tea.View {
@@ -15,26 +14,43 @@ func (m model) View() tea.View {
1514 1615 switch m.state {1716 case mainMenuView:18- content = ui.CenterView(m.width, m.height, m.mainMenuList.View())17+ content = ui.CenterView(m.width, m.height, m.mainMenu.View())1918 case gameSelectView:20- content = ui.CenterView(m.width, m.height, m.gameSelectList.View())19+ panel := ui.Panel(20+ "Select Category",21+ m.gameSelectList.View(),22+ "↑/↓ navigate • enter select • esc back",23+ )24+ content = ui.CenterView(m.width, m.height, panel)2125 case modeSelectView:22- content = ui.CenterView(m.width, m.height, m.modeSelectList.View())26+ panel := ui.Panel(27+ m.selectedCategory.Name+" — Select Mode",28+ m.modeSelectList.View(),29+ "↑/↓ navigate • enter select • esc back",30+ )31+ content = ui.CenterView(m.width, m.height, panel)2332 case generatingView:2433 s := m.spinner.View() + " Generating puzzle..."25- content = ui.CenterView(m.width, m.height, s)34+ box := ui.GeneratingFrame.Render(s)35+ content = ui.CenterView(m.width, m.height, box)2636 case continueView:2737 var s string2838 if len(m.continueGames) == 0 {29- s = "No saved games yet.\n\nPress Escape to return."39+ s = ui.Panel(40+ "Saved Games",41+ "No saved games yet.",42+ "esc back",43+ )3044 } else {31- title := lipgloss.NewStyle().32- Bold(true).33- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("255"), Dark: lipgloss.Color("255")}).34- Background(ui.MenuAccent).35- Padding(0, 1).36- Render("Saved Games")37- s = lipgloss.JoinVertical(lipgloss.Left, title, "", m.continueTable.View())45+ footer := "↑/↓ navigate • enter resume • esc back"46+ if pg := ui.TablePagination(m.continueTable); pg != "" {47+ footer = pg + " " + footer48+ }49+ s = ui.Panel(50+ "Saved Games",51+ m.continueTable.View(),52+ footer,53+ )3854 }3955 content = ui.CenterView(m.width, m.height, s)4056 case gameView:
@@ -52,16 +68,19 @@ func (m model) View() tea.View {
5268 content = ui.CenterView(m.width, m.height, centered)5369 }5470 case helpSelectView:55- content = ui.CenterView(m.width, m.height, m.helpSelectList.View())71+ panel := ui.Panel(72+ "How to Play",73+ m.helpSelectList.View(),74+ "↑/↓ navigate • enter select • esc back",75+ )76+ content = ui.CenterView(m.width, m.height, panel)5677 case helpDetailView:57- footer := lipgloss.NewStyle().58- Foreground(ui.MenuTextDim).59- Render("↑/↓ scroll • esc back")60- s := lipgloss.JoinVertical(lipgloss.Left,78+ panel := ui.Panel(79+ m.helpCategory.Name+" — Guide",6180 m.helpViewport.View(),62- footer,81+ "↑/↓ scroll • esc back",6382 )64- content = ui.CenterView(m.width, m.height, s)83+ content = ui.CenterView(m.width, m.height, panel)6584 default:6685 content = fmt.Sprintf("unknown state: %d", m.state)6786 }