clone

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

Use theme palette colors for category hover bars

Dami committed cf9edaf
Rename Palette.CardColors() to ThemeColors() and update consumers.
commit
cf9edafd50ce1054bc316a6dab8326f014c6e1a1
parent
48bc9c709b2eb5656e35f4e0189f66b4a5453bee
author
Dami <dami.etoile@gmail.com>
10 files changed, +263 -93
Mapp/model.go4+-
Mapp/update.go107++++++++++++++++++++----------
Mapp/view.go8+-
Mhashiwokakero/style.go2+-
Mshikaku/style.go2+-
Mstats/stats.go65+++++++++++-------
Mstats/stats_test.go71++++++++++++++++++++
Mtheme/palette.go10+-
Mui/list.go75++++++++++++++++-----
Mui/ui_test.go12+++
M app/model.go +2 -2
M app/update.go +72 -35
@@ -23,6 +23,71 @@ import (
2323 "github.com/charmbracelet/glamour"2424 )2525 26+const (27+ helpPanelInsetX = 228+ helpPanelInsetY = 129+ helpPanelHorizontalTrim = 630+ helpPanelVerticalTrim = 831+)32+33+func helpViewportSize(width, height int) (int, int) {34+ panelWidth := max(width-(helpPanelInsetX*2), 1)35+ panelHeight := max(height-(helpPanelInsetY*2), 1)36+ contentWidth := max(panelWidth-helpPanelHorizontalTrim, 1)37+ contentHeight := max(panelHeight-helpPanelVerticalTrim, 1)38+ return contentWidth, contentHeight39+}40+41+func statsViewportSize(width, height int, cards []stats.Card) (int, int) {42+ contentWidth, _ := helpViewportSize(width, height)43+ panelHeight := max(height-(helpPanelInsetY*2), 1)44+ contentHeight := max(panelHeight-stats.StaticHeight(cards), 1)45+ return contentWidth, contentHeight46+}47+48+func (m model) updateHelpDetailViewport() model {49+ helpWidth, helpHeight := helpViewportSize(m.width, m.height)50+ if m.helpRenderer == nil || m.helpRendererWidth != helpWidth {51+ renderer, err := glamour.NewTermRenderer(52+ glamour.WithAutoStyle(),53+ glamour.WithWordWrap(helpWidth),54+ )55+ if err != nil {56+ log.Printf("failed to create help renderer: %v", err)57+ m.helpRenderer = nil58+ m.helpRendererWidth = 059+ } else {60+ m.helpRenderer = renderer61+ m.helpRendererWidth = helpWidth62+ }63+ }64+65+ rendered := m.helpCategory.Help66+ if m.helpRenderer != nil {67+ out, err := m.helpRenderer.Render(m.helpCategory.Help)68+ if err != nil {69+ log.Printf("failed to render help: %v", err)70+ } else {71+ rendered = out72+ }73+ }74+75+ m.helpViewport = viewport.New(76+ viewport.WithWidth(helpWidth),77+ viewport.WithHeight(helpHeight),78+ )79+ m.helpViewport.SetContent(rendered)80+ return m81+}82+83+func (m model) updateStatsViewport() model {84+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.statsCards)85+ m.statsViewport.SetWidth(statsWidth)86+ m.statsViewport.SetHeight(statsHeight)87+ m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, statsWidth))88+ return m89+}90+2691 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {2792 var cmd tea.Cmd2893
@@ -53,8 +118,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
53118 m.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.helpSelectList)))54119 }55120 if m.state == helpDetailView {56- m.helpViewport.SetWidth(m.width)57- m.helpViewport.SetHeight(m.height - 2)121+ m = m.updateHelpDetailViewport()58122 }59123 if m.state == themeSelectView {60124 const maxVisibleItems = 8
@@ -62,11 +126,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62126 m.themeList.SetSize(listW, min(m.height, maxVisibleItems*3))63127 }64128 if m.state == statsView {65- cw := stats.ContentWidth(m.width)66- m.statsViewport.SetWidth(cw)67- vpH := stats.ViewportHeight(m.height - stats.StaticHeight(m.statsCards))68- m.statsViewport.SetHeight(vpH)69- m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, cw))129+ m = m.updateStatsViewport()70130 }71131 72132 case tea.KeyPressMsg:
@@ -403,27 +463,7 @@ func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {
403463 return m, nil404464 }405465 m.helpCategory = cat406-407- if m.helpRenderer == nil || m.helpRendererWidth != m.width {408- renderer, err := glamour.NewTermRenderer(409- glamour.WithAutoStyle(),410- glamour.WithWordWrap(m.width),411- )412- if err != nil {413- log.Printf("failed to create help renderer: %v", err)414- return m, nil415- }416- m.helpRenderer = renderer417- m.helpRendererWidth = m.width418- }419- rendered, err := m.helpRenderer.Render(cat.Help)420- if err != nil {421- log.Printf("failed to render help: %v", err)422- return m, nil423- }424-425- m.helpViewport = viewport.New(viewport.WithWidth(m.width), viewport.WithHeight(m.height-2))426- m.helpViewport.SetContent(rendered)466+ m = m.updateHelpDetailViewport()427467 m.state = helpDetailView428468 return m, nil429469 }
@@ -548,15 +588,12 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
548588 m.statsCards = stats.BuildCards(catStats, modeStats)549589 m.statsProfile = stats.BuildProfileBanner(catStats, modeStats, streakDates, currentDaily)550590 551- // Banner is rendered as static chrome above the viewport; only the552- // card grid scrolls. Compute available height for the viewport after553- // accounting for the panel frame and banner.554- vpHeight := stats.ViewportHeight(m.height - stats.StaticHeight(m.statsCards))591+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.statsCards)555592 m.statsViewport = viewport.New(556- viewport.WithWidth(stats.ContentWidth(m.width)),557- viewport.WithHeight(vpHeight),593+ viewport.WithWidth(statsWidth),594+ viewport.WithHeight(statsHeight),558595 )559- m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, stats.ContentWidth(m.width)))596+ m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, statsWidth))560597 m.state = statsView561598 return m, nil562599 }
M app/view.go +3 -5
M hashiwokakero/style.go +1 -1
M shikaku/style.go +1 -1
M stats/stats.go +39 -26
@@ -241,17 +241,19 @@ func StaticHeight(cards []Card) int {
241241 // CardFullWidth is the rendered width of a single card including border + padding.242242 const CardFullWidth = CardInnerWidth + 4 // 34243243 244+const cardColumnGap = 1245+244246 // ContentWidth returns the inner content width for the stats panel.245-// In 2-column mode the content is two cards wide; in 1-column mode it is246-// one card wide. The result is clamped so it never exceeds the available247-// terminal width minus the panel chrome (border + padding = 6 horizontal chars).247+// It snaps to the widest whole-card grid that fits the available panel space.248+// The result never exceeds terminal width minus panel chrome.248249 func ContentWidth(termWidth int) int {249- twoCol := CardFullWidth * 2 // 68250- available := termWidth - 6 // panel border (1+1) + padding (2+2)251- if available >= twoCol {252- return twoCol250+ available := max(termWidth-6, CardFullWidth) // panel border (1+1) + padding (2+2)251+ cols := cardColumnCount(available)252+ width := cardGridWidth(cols)253+ if width > available {254+ return available253255 }254- return max(CardFullWidth, min(available, CardFullWidth))256+ return width255257 }256258 257259 // ViewportHeight computes the viewport height for the card grid.
@@ -433,10 +435,7 @@ func RenderCardGrid(cards []Card, width int) string {
433435 Render("No stats yet \u2014 play some puzzles!")434436 }435437 436- if width >= CardFullWidth*2 {437- return renderCardColumns(cards, 2)438- }439- return renderCardColumns(cards, 1)438+ return renderCardRows(cards, cardColumnCount(width))440439 }441440 442441 // RenderView renders the full stats view (banner + card grid) as a single
@@ -448,11 +447,7 @@ func RenderView(banner ProfileBanner, cards []Card, width int) string {
448447 Render("No stats yet \u2014 play some puzzles!")449448 }450449 451- bannerWidth := width452- if bannerWidth > 70 {453- bannerWidth = 70454- }455- bannerStr := RenderBanner(banner, bannerWidth)450+ bannerStr := RenderBanner(banner, width)456451 cardGrid := RenderCardGrid(cards, width)457452 458453 return lipgloss.JoinVertical(lipgloss.Left,
@@ -462,24 +457,42 @@ func RenderView(banner ProfileBanner, cards []Card, width int) string {
462457 )463458 }464459 465-func renderCardColumns(cards []Card, cols int) string {460+func cardColumnCount(width int) int {461+ if width <= CardFullWidth {462+ return 1463+ }464+ cols := (width + cardColumnGap) / (CardFullWidth + cardColumnGap)465+ return max(cols, 1)466+}467+468+func cardGridWidth(cols int) int {466469 if cols < 1 {467470 cols = 1468471 }472+ return cols*CardFullWidth + (cols-1)*cardColumnGap473+}469474 470- palette := theme.Current().CardColors()475+func renderCardRows(cards []Card, cols int) string {476+ if cols < 1 {477+ cols = 1478+ }471479 472- columns := make([][]string, cols)480+ palette := theme.Current().ThemeColors()481+ rendered := make([]string, len(cards))473482 for i, c := range cards {474- col := i % cols475483 titleColor := palette[i%len(palette)]476- columns[col] = append(columns[col], RenderCard(c, titleColor))484+ rendered[i] = RenderCard(c, titleColor)477485 }478486 479- rendered := make([]string, cols)480- for i, col := range columns {481- rendered[i] = lipgloss.JoinVertical(lipgloss.Left, col...)487+ rows := make([]string, 0, (len(rendered)+cols-1)/cols)488+ for i := 0; i < len(rendered); i += cols {489+ end := min(i+cols, len(rendered))490+ row := rendered[i]491+ for j := i + 1; j < end; j++ {492+ row = lipgloss.JoinHorizontal(lipgloss.Top, row, strings.Repeat(" ", cardColumnGap), rendered[j])493+ }494+ rows = append(rows, row)482495 }483496 484- return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)497+ return lipgloss.JoinVertical(lipgloss.Left, rows...)485498 }
M stats/stats_test.go +71 -0
@@ -1,10 +1,12 @@
11 package stats22 33 import (4+ "strings"45 "testing"56 "time"67 78 "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/charmbracelet/x/ansi"810 )911 1012 // --- ModeXP map (P0) ---
@@ -333,3 +335,72 @@ func TestRenderViewEmpty(t *testing.T) {
333335 t.Error("expected non-empty output for empty state")334336 }335337 }338+339+// --- Card grid layout (P1) ---340+341+func TestCardColumnCount(t *testing.T) {342+ tests := []struct {343+ name string344+ width int345+ want int346+ }{347+ {name: "narrow", width: 20, want: 1},348+ {name: "single card", width: CardFullWidth, want: 1},349+ {name: "two cards", width: cardGridWidth(2), want: 2},350+ {name: "three cards", width: cardGridWidth(3), want: 3},351+ }352+353+ for _, tt := range tests {354+ t.Run(tt.name, func(t *testing.T) {355+ if got := cardColumnCount(tt.width); got != tt.want {356+ t.Fatalf("cardColumnCount(%d) = %d, want %d", tt.width, got, tt.want)357+ }358+ })359+ }360+}361+362+func TestRenderCardGridWrapsRowsByWidth(t *testing.T) {363+ cards := testCards(5)364+ width := cardGridWidth(3)365+ rendered := ansi.Strip(RenderCardGrid(cards, width))366+ lines := strings.Split(rendered, "\n")367+ if len(lines) <= CardHeight {368+ t.Fatalf("expected wrapped second row, got %d lines", len(lines))369+ }370+ if got := strings.Count(lines[0], "╭"); got != 3 {371+ t.Fatalf("first row cards = %d, want 3", got)372+ }373+ if got := strings.Count(lines[CardHeight], "╭"); got != 2 {374+ t.Fatalf("second row cards = %d, want 2", got)375+ }376+ if got := maxRenderedWidth(rendered); got > width {377+ t.Fatalf("rendered width = %d, want <= %d", got, width)378+ }379+}380+381+func testCards(n int) []Card {382+ cards := make([]Card, n)383+ for i := range n {384+ cards[i] = Card{385+ GameType: "Test",386+ Level: 1,387+ PreferredMode: "Easy",388+ Victories: 1,389+ Attempts: 1,390+ DailyPlayed: 1,391+ CurrentXP: 1,392+ NextLevelXP: 5,393+ }394+ }395+ return cards396+}397+398+func maxRenderedWidth(s string) int {399+ maxW := 0400+ for _, line := range strings.Split(s, "\n") {401+ if w := ansi.StringWidth(line); w > maxW {402+ maxW = w403+ }404+ }405+ return maxW406+}
M theme/palette.go +5 -5
M ui/list.go +57 -18
@@ -1,10 +1,54 @@
11 package ui22 33 import (4+ "io"5+46 "charm.land/bubbles/v2/list"57 "github.com/FelineStateMachine/puzzletea/theme"68 )79 10+type categoryColorDelegate struct {11+ list.DefaultDelegate12+}13+14+func (d categoryColorDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {15+ delegate := d.DefaultDelegate16+ palette := theme.Current().ThemeColors()17+ if len(palette) > 0 {18+ barColor := palette[index%len(palette)]19+ delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.20+ BorderLeftForeground(barColor)21+ delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.22+ BorderLeftForeground(barColor)23+ }24+ delegate.Render(w, m, index, item)25+}26+27+func newMenuDelegate(p theme.Palette) list.DefaultDelegate {28+ d := list.NewDefaultDelegate()29+ d.Styles.SelectedTitle = d.Styles.SelectedTitle.30+ Foreground(p.Accent).31+ BorderLeftForeground(p.Accent)32+ d.Styles.SelectedDesc = d.Styles.SelectedDesc.33+ Foreground(p.AccentSoft).34+ BorderLeftForeground(p.Accent)35+ d.Styles.NormalTitle = d.Styles.NormalTitle.36+ Foreground(p.FG)37+ d.Styles.NormalDesc = d.Styles.NormalDesc.38+ Foreground(p.TextDim)39+ return d40+}41+42+func configureMenuList(l *list.Model, title string) {43+ l.Title = title44+ l.SetShowTitle(false)45+ l.DisableQuitKeybindings()46+ l.SetFilteringEnabled(false)47+ l.SetShowHelp(false)48+ l.SetShowStatusBar(false)49+ l.SetShowPagination(false)50+}51+852 func applyThemeListStyles(d *list.DefaultDelegate, p theme.Palette, width int) {953 // Title styles intentionally avoid fixed Width: bubbles uses them for1054 // rune-level filter highlighting and Width can force wrapped segments.
@@ -29,26 +73,21 @@ func applyThemeListStyles(d *list.DefaultDelegate, p theme.Palette, width int) {
2973 // that provide their own styled title.3074 func InitList(items []list.Item, title string) list.Model {3175 p := theme.Current()32- d := list.NewDefaultDelegate()33- d.Styles.SelectedTitle = d.Styles.SelectedTitle.34- Foreground(p.Accent).35- BorderLeftForeground(p.Accent)36- d.Styles.SelectedDesc = d.Styles.SelectedDesc.37- Foreground(p.AccentSoft).38- BorderLeftForeground(p.Accent)39- d.Styles.NormalTitle = d.Styles.NormalTitle.40- Foreground(p.FG)41- d.Styles.NormalDesc = d.Styles.NormalDesc.42- Foreground(p.TextDim)76+ d := newMenuDelegate(p)4377 4478 l := list.New(items, d, 64, 64)45- l.Title = title46- l.SetShowTitle(false)47- l.DisableQuitKeybindings()48- l.SetFilteringEnabled(false)49- l.SetShowHelp(false)50- l.SetShowStatusBar(false)51- l.SetShowPagination(false)79+ configureMenuList(&l, title)80+ return l81+}82+83+// InitCategoryList creates the category picker list with per-item hover bar84+// colors sourced from the active theme color set.85+func InitCategoryList(items []list.Item, title string) list.Model {86+ p := theme.Current()87+ d := categoryColorDelegate{DefaultDelegate: newMenuDelegate(p)}88+89+ l := list.New(items, d, 64, 64)90+ configureMenuList(&l, title)5291 return l5392 }5493
M ui/ui_test.go +12 -0