clone

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

feat/app-decomposition
puzzletea / app / view.go
view.go 5.4K
1package app23import (4 "fmt"5 "strings"67 "github.com/FelineStateMachine/puzzletea/registry"8 "github.com/FelineStateMachine/puzzletea/theme"9 "github.com/FelineStateMachine/puzzletea/ui"1011 "charm.land/bubbles/v2/list"12 "charm.land/bubbles/v2/viewport"13 tea "charm.land/bubbletea/v2"14 "charm.land/lipgloss/v2"15)1617func (m model) View() tea.View {18 v := tea.NewView(m.viewContent())19 v.AltScreen = true20 if m.state == gameView || m.state == exportView {21 v.MouseMode = tea.MouseModeCellMotion22 v.KeyboardEnhancements.ReportEventTypes = true23 }24 return v25}2627func (m model) viewContent() string {28 if m.state == gameView {29 return m.renderGameView()30 }3132 screen := m.activeScreen()33 if screen == nil {34 return fmt.Sprintf("unknown state: %d", m.state)35 }36 return screen.View(m.notice)37}3839func gameSelectViewContent(width, height int, gameList list.Model, detail viewport.Model, notice noticeState) string {40 metrics := categoryPickerSize(width, height)4142 listView := lipgloss.NewStyle().43 Width(metrics.listWidth).44 Height(metrics.listHeight).45 Border(lipgloss.NormalBorder(), false, true, false, false).46 BorderForeground(theme.Current().Border).47 Render(gameList.View())4849 detailBox := lipgloss.NewStyle().50 Width(metrics.detailWidth).51 Height(metrics.detailHeight).52 Padding(2, 3).53 Render(detail.View())5455 body := lipgloss.JoinHorizontal(lipgloss.Top, listView, strings.Repeat(" ", categoryGapWidth), detailBox)56 if metrics.stacked {57 body = lipgloss.JoinVertical(lipgloss.Left, listView, "", detailBox)58 }59 body = lipgloss.NewStyle().60 Width(metrics.bodyWidth).61 Height(metrics.bodyHeight).62 Render(body)6364 panel := ui.Panel(65 "Select Category",66 appendNoticeContent(width, notice, body),67 "↑/↓ navigate • / filter • enter select • esc back",68 )69 return ui.CenterView(width, height, panel)70}7172func weeklyViewContent(w weeklyState) string {73 title := "Weekly Gauntlet — " + weeklyPanelTitle(w.cursor)74 if len(w.rows) == 0 {75 body := "No completed puzzles for this week yet."76 if isCurrentWeeklySelection(w.cursor) {77 body = "No weekly puzzles are available."78 }79 return ui.Panel(80 title,81 body,82 "←/→ week • esc back",83 )84 }8586 footer := "←/→ week • enter open • esc back"87 if !isCurrentWeeklySelection(w.cursor) {88 footer = "←/→ week • enter review • esc back"89 }90 if pg := ui.TablePagination(w.table); pg != "" {91 footer = pg + " " + footer92 }9394 description := w.table.View()95 if !isCurrentWeeklySelection(w.cursor) {96 description = lipgloss.JoinVertical(97 lipgloss.Left,98 ui.DimItemStyle().Render("Review only: completed puzzles from this week."),99 "",100 description,101 )102 }103104 return ui.Panel(title, description, footer)105}106107func categoryPickerListHeight() int {108 const (109 maxVisibleItems = 8110 filterChrome = 2111 )112113 return maxVisibleItems + filterChrome114}115116func renderCategoryDetailContent(entry registry.Entry, width int) string {117 p := theme.Current()118 desc := lipgloss.NewStyle().119 Foreground(p.FG).120 Width(width).121 Render(entry.Definition.Description)122123 meta := ui.DimItemStyle().Render(fmt.Sprintf("%d modes available", len(entry.Modes)))124 content := lipgloss.JoinVertical(lipgloss.Left,125 ui.PanelTitle().Render(entry.Definition.Name),126 meta,127 "",128 desc,129 "",130 ui.SelectedItemStyle().Render("Modes"),131 renderModeList(entry, width),132 )133 return content134}135136func renderModeList(entry registry.Entry, width int) string {137 if len(entry.Modes) == 0 {138 return ui.DimItemStyle().Render("No modes available.")139 }140141 displayTitles := modeDisplayTitles(entry)142 modeNames := make([]string, 0, len(displayTitles))143 for _, title := range displayTitles {144 modeNames = append(modeNames, "• "+title)145 }146 if len(modeNames) == 0 {147 return ui.DimItemStyle().Render("No modes available.")148 }149150 if width < 24 || len(modeNames) == 1 {151 lines := make([]string, 0, len(modeNames))152 for _, name := range modeNames {153 lines = append(lines, lipgloss.NewStyle().Width(width).Render(name))154 }155 return strings.Join(lines, "\n")156 }157158 rows := (len(modeNames) + 1) / 2159 leftWidth := max(width/2-1, 1)160 rightWidth := max(width-leftWidth-2, 1)161162 left := make([]string, 0, rows)163 right := make([]string, 0, rows)164 for i, name := range modeNames {165 line := lipgloss.NewStyle().Width(leftWidth).Render(name)166 if i < rows {167 left = append(left, line)168 continue169 }170 line = lipgloss.NewStyle().Width(rightWidth).Render(name)171 right = append(right, line)172 }173 for len(right) < len(left) {174 right = append(right, strings.Repeat(" ", rightWidth))175 }176177 lines := make([]string, 0, rows)178 for i := range left {179 lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, left[i], " ", right[i]))180 }181 return strings.Join(lines, "\n")182}183184func themeSelectViewContent(width, height int, thm themeState, notice noticeState) string {185 p := theme.Current()186187 themeName := theme.DefaultThemeName188 if item, ok := thm.list.SelectedItem().(ui.MenuItem); ok {189 themeName = item.ItemTitle190 }191192 const panelChrome = 8193 innerH := height - panelChrome194 innerH = max(innerH, 10)195196 listView := thm.list.View()197198 previewBorder := lipgloss.NewStyle().199 Border(lipgloss.RoundedBorder()).200 BorderForeground(p.Border).201 Padding(1, 2)202203 preview := theme.PreviewPanel(themeName, innerH-4)204 previewBox := previewBorder.Render(preview)205206 body := lipgloss.JoinHorizontal(lipgloss.Top, listView, " ", previewBox)207208 panel := ui.Panel(209 "Select Theme",210 appendNoticeContent(width, notice, body),211 "↑/↓ navigate • / filter • enter select • esc back",212 )213 return ui.CenterView(width, height, panel)214}