Rename Palette.CardColors() to ThemeColors() and update consumers.
- commit
- cf9edafd50ce1054bc316a6dab8326f014c6e1a1
- parent
- 48bc9c709b2eb5656e35f4e0189f66b4a5453bee
- author
- Dami <dami.etoile@gmail.com>
| M | app/ | 4 | +- |
| M | app/ | 107 | ++++++++++++++++++++---------- |
| M | app/ | 8 | +- |
| M | hashiwokakero/ | 2 | +- |
| M | shikaku/ | 2 | +- |
| M | stats/ | 65 | +++++++++++------- |
| M | stats/ | 71 | ++++++++++++++++++++ |
| M | theme/ | 10 | +- |
| M | ui/ | 75 | ++++++++++++++++----- |
| M | ui/ | 12 | +++ |
@@ -168,7 +168,7 @@ func newSpinner() spinner.Model {
168168 // InitialModel creates the root TUI model for the main menu.169169 func InitialModel(s *store.Store, cfg *config.Config) model {170170 r := initDebugRenderer()171- l := ui.InitList(GameCategories, "Select Category")171+ l := ui.InitCategoryList(GameCategories, "Select Category")172172 mm := ui.NewMainMenu(mainMenuItems)173173 return model{174174 state: mainMenuView,@@ -185,7 +185,7 @@ func InitialModel(s *store.Store, cfg *config.Config) model {
185185 // bypassing the menu. Used by CLI flags (--new, --continue).186186 func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, activeGameID int64, completionSaved bool) model {187187 r := initDebugRenderer()188- l := ui.InitList(GameCategories, "Select Category")188+ l := ui.InitCategoryList(GameCategories, "Select Category")189189 mm := ui.NewMainMenu(mainMenuItems)190190 return model{191191 state: gameView,@@ -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 }@@ -99,21 +99,19 @@ func (m model) View() tea.View {
9999 case themeSelectView:100100 content = m.themeSelectViewContent()101101 case statsView:102+ statsWidth, _ := statsViewportSize(m.width, m.height, m.statsCards)102103 var statsBody string103104 if len(m.statsCards) == 0 {104105 statsBody = m.statsViewport.View()105106 } else {106- bannerWidth := stats.ContentWidth(m.width)107- if bannerWidth > 70 {108- bannerWidth = 70109- }110- banner := stats.RenderBanner(m.statsProfile, bannerWidth)107+ banner := stats.RenderBanner(m.statsProfile, statsWidth)111108 statsBody = lipgloss.JoinVertical(lipgloss.Left,112109 banner,113110 "",114111 m.statsViewport.View(),115112 )116113 }114+ statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)117115 panel := ui.Panel(118116 "Stats",119117 statsBody,@@ -12,7 +12,7 @@ import (
1212 1313 const cellWidth = 31414 15-func islandColors() []color.Color { return theme.Current().CardColors() }15+func islandColors() []color.Color { return theme.Current().ThemeColors() }1616 1717 func islandDefaultStyle(islandID int) lipgloss.Style {1818 colors := islandColors()@@ -15,7 +15,7 @@ const cellWidth = 3
1515 // rectColors returns the current theme's chromatic ANSI colors for1616 // rectangle backgrounds. Colors follow the active palette, so they1717 // adapt automatically when the user switches themes.18-func rectColors() []color.Color { return theme.Current().CardColors() }18+func rectColors() []color.Color { return theme.Current().ThemeColors() }1919 2020 func gridBorderStyle() lipgloss.Style {2121 return lipgloss.NewStyle().@@ -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 }@@ -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+}@@ -55,11 +55,11 @@ type Palette struct {
5555 ANSI [16]color.Color5656 }5757 58-// CardColors returns a curated slice of distinct, colorful palette colors59-// suitable for differentiating UI cards. The colors are drawn from the60-// chromatic ANSI slots (skipping black/white/gray) and the semantic accent61-// colors, giving up to 10 visually distinct options per theme.62-func (p Palette) CardColors() []color.Color {58+// ThemeColors returns a curated slice of distinct, colorful palette colors59+// suitable for differentiating themed UI elements (cards, hover bars, puzzle60+// regions, etc). The colors are drawn from chromatic ANSI slots (skipping61+// black/white/gray), giving up to 10 visually distinct options per theme.62+func (p Palette) ThemeColors() []color.Color {6363 return []color.Color{6464 p.ANSI[3], // yellow6565 p.ANSI[4], // blue@@ -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 @@ -97,6 +97,18 @@ func TestInitListDisablesQuitAndFilter(t *testing.T) {
9797 }9898 }9999 100+func TestInitCategoryListDisablesQuitAndFilter(t *testing.T) {101+ l := InitCategoryList(nil, "Categories")102+103+ if l.FilteringEnabled() {104+ t.Error("expected filtering to be disabled")105+ }106+107+ if l.ShowHelp() {108+ t.Error("expected help to be disabled")109+ }110+}111+100112 func TestThemeListFilterDoesNotSplitTitle(t *testing.T) {101113 longest := longestThemeName(t)102114