- commit
- 60ec793a610730b275625a9c7ca7c3515067d903
- parent
- 1a915576c482edc8af57a81298887be8f463d2cc
- author
- Dami <dami.etoile@gmail.com>
| M | app/ | 222 | ++++++++++++++---------- |
| M | app/ | 29 | ++- |
| M | app/ | 23 | +-- |
| M | app/ | 282 | +++++++++++++++++------------- |
| M | app/ | 83 | +-------- |
@@ -24,6 +24,28 @@ import (
2424 "charm.land/lipgloss/v2"2525 )2626 27+type exportModel struct {28+ initialized bool29+ focus exportFocus30+ values exportFormValues31+ titleInput textinput.Model32+ headerInput textarea.Model33+ advertInput textinput.Model34+ volumeInput textinput.Model35+ seedInput textinput.Model36+ pdfPathInput textinput.Model37+ jsonlPathInput textinput.Model38+ cards []exportGameCard39+ cardIndex int40+ bucketIndex int41+ cardRowOffset int42+ running bool43+ jobID int6444+ cancel context.CancelFunc45+ width int46+ height int47+}48+2749 type exportFocus int2850 2951 const (@@ -109,35 +131,27 @@ type exportCompleteMsg struct {
109131 err error110132 }111133 112-type exportScreen struct {113- width int114- height int115- export exportState116-}117-118-func (s exportScreen) State() viewState { return exportView }119-120-func (s exportScreen) Resize(width, height int) screenModel {121- s.width = width122- s.height = height123- contentWidth, contentHeight := exportContentBounds(width, height)124- s.export.resize(contentWidth)125- s.export.ensureCardSelectionVisible(contentWidth, contentHeight)126- return s127-}134+// exportSubmitCmd and exportBackCmd are sentinel commands emitted by135+// exportModel.Update to communicate submit/cancel to the root model.136+var (137+ exportSubmitCmd = func() tea.Msg { return exportSubmitAction{} }138+ exportBackCmd = func() tea.Msg { return backAction{target: mainMenuView} }139+)128140 129-func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {141+// Update handles all export form input and returns sentinel commands for142+// submit (exportSubmitCmd) and cancel (exportBackCmd).143+func (s *exportModel) Update(msg tea.Msg) (exportModel, tea.Cmd) {130144 if clickMsg, ok := msg.(tea.MouseClickMsg); ok {131145 contentWidth, contentHeight := exportContentBounds(s.width, s.height)132- if cmd, action, handled := s.export.handleMouseClick(clickMsg, s.width, s.height, contentWidth, contentHeight); handled {133- return s, cmd, action146+ if cmd, handled := s.handleMouseClick(clickMsg, s.width, s.height, contentWidth, contentHeight); handled {147+ return *s, cmd134148 }135- return s, nil, nil149+ return *s, nil136150 }137151 if wheelMsg, ok := msg.(tea.MouseWheelMsg); ok {138152 contentWidth, contentHeight := exportContentBounds(s.width, s.height)139- if handled := s.export.handleMouseWheel(wheelMsg, s.width, s.height, contentWidth, contentHeight); handled {140- return s, nil, nil153+ if handled := s.handleMouseWheel(wheelMsg, s.width, s.height, contentWidth, contentHeight); handled {154+ return *s, nil141155 }142156 }143157 @@ -145,39 +159,64 @@ func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
145159 if ok {146160 switch {147161 case key.Matches(keyMsg, rootKeys.Escape):148- return s, nil, backAction{target: mainMenuView}162+ return *s, exportBackCmd149163 case keyMsg.String() == "tab":150- cmd := s.export.moveFocus(1)151- return s, cmd, nil164+ cmd := s.moveFocus(1)165+ return *s, cmd152166 case keyMsg.String() == "shift+tab":153- cmd := s.export.moveFocus(-1)154- return s, cmd, nil167+ cmd := s.moveFocus(-1)168+ return *s, cmd155169 case key.Matches(keyMsg, rootKeys.Enter):156- if s.export.focus == exportFocusSubmit {157- return s, nil, exportSubmitAction{}170+ if s.focus == exportFocusSubmit {171+ return *s, exportSubmitCmd158172 }159- if s.export.focus == exportFocusHeader {173+ if s.focus == exportFocusHeader {160174 break161175 }162- if s.export.focus == exportFocusLayout {163- s.export.cycleLayout(1)164- return s, nil, nil176+ if s.focus == exportFocusLayout {177+ s.cycleLayout(1)178+ return *s, nil165179 }166- if s.export.focus == exportFocusJSONLToggle {167- s.export.toggleJSONL()168- return s, nil, nil180+ if s.focus == exportFocusJSONLToggle {181+ s.toggleJSONL()182+ return *s, nil169183 }170- cmd := s.export.moveFocus(1)171- return s, cmd, nil184+ cmd := s.moveFocus(1)185+ return *s, cmd172186 }173187 174188 contentWidth, contentHeight := exportContentBounds(s.width, s.height)175- if handled := s.export.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {176- return s, nil, nil189+ if handled := s.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {190+ return *s, nil177191 }178192 }179193 180- cmd := s.export.updateFocusedInput(msg)194+ cmd := s.updateFocusedInput(msg)195+ return *s, cmd196+}197+198+type exportScreen struct {199+ width int200+ height int201+ export exportModel202+}203+204+func (s exportScreen) State() viewState { return exportView }205+206+func (s exportScreen) Resize(width, height int) screenModel {207+ s.width = width208+ s.height = height209+ s.export.width = width210+ s.export.height = height211+ contentWidth, contentHeight := exportContentBounds(width, height)212+ s.export.resize(contentWidth)213+ s.export.ensureCardSelectionVisible(contentWidth, contentHeight)214+ return s215+}216+217+func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {218+ next, cmd := s.export.Update(msg)219+ s.export = next181220 return s, cmd, nil182221 }183222 @@ -312,13 +351,14 @@ func exportCmd(ctx context.Context, jobID int64, spec packexport.Spec) tea.Cmd {
312351 }313352 }314353 315-func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportState {354+func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportModel {316355 contentWidth, _ := exportContentBounds(width, 0)317- state := exportState{356+ state := exportModel{318357 initialized: true,319358 focus: exportFocusCards,320359 values: values,321360 cards: cards,361+ width: width,322362 }323363 state.initInputs(contentWidth)324364 return state@@ -444,7 +484,7 @@ func bucketIndexForMode(index, total int) int {
444484 return index * 3 / total445485 }446486 447-func (s *exportState) initInputs(width int) {487+func (s *exportModel) initInputs(width int) {448488 s.titleInput = newExportInput(s.values.Title, "Sampler title")449489 s.headerInput = newExportTextarea(s.values.Header, "Short intro")450490 s.advertInput = newExportInput(s.values.Advert, "Footer copy")@@ -511,7 +551,7 @@ func styleExportTextarea(input *textarea.Model) {
511551 input.SetStyles(styles)512552 }513553 514-func (s *exportState) resize(width int) {554+func (s *exportModel) resize(width int) {515555 styleExportInput(&s.titleInput)516556 styleExportTextarea(&s.headerInput)517557 styleExportInput(&s.advertInput)@@ -531,7 +571,7 @@ func (s *exportState) resize(width int) {
531571 s.jsonlPathInput.SetWidth(max(26, min(width-20, 72)))532572 }533573 534-func (s *exportState) visibleFocuses() []exportFocus {574+func (s *exportModel) visibleFocuses() []exportFocus {535575 focuses := []exportFocus{536576 exportFocusTitle,537577 exportFocusAdvert,@@ -549,7 +589,7 @@ func (s *exportState) visibleFocuses() []exportFocus {
549589 return focuses550590 }551591 552-func (s *exportState) moveFocus(delta int) tea.Cmd {592+func (s *exportModel) moveFocus(delta int) tea.Cmd {553593 focuses := s.visibleFocuses()554594 current := 0555595 for i, focus := range focuses {@@ -566,7 +606,7 @@ func (s *exportState) moveFocus(delta int) tea.Cmd {
566606 return s.applyFocus()567607 }568608 569-func (s *exportState) applyFocus() tea.Cmd {609+func (s *exportModel) applyFocus() tea.Cmd {570610 s.titleInput.Blur()571611 s.headerInput.Blur()572612 s.advertInput.Blur()@@ -595,7 +635,7 @@ func (s *exportState) applyFocus() tea.Cmd {
595635 }596636 }597637 598-func (s *exportState) updateFocusedInput(msg tea.Msg) tea.Cmd {638+func (s *exportModel) updateFocusedInput(msg tea.Msg) tea.Cmd {599639 var cmd tea.Cmd600640 switch s.focus {601641 case exportFocusTitle:@@ -623,7 +663,7 @@ func (s *exportState) updateFocusedInput(msg tea.Msg) tea.Cmd {
623663 return cmd624664 }625665 626-func (s *exportState) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {666+func (s *exportModel) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {627667 switch s.focus {628668 case exportFocusLayout:629669 switch msg.String() {@@ -692,7 +732,7 @@ func (s *exportState) handleNavigationKey(msg tea.KeyPressMsg, width, height int
692732 return false693733 }694734 695-func (s *exportState) moveBucketSelection(delta, width, height int) {735+func (s *exportModel) moveBucketSelection(delta, width, height int) {696736 if len(s.cards) == 0 || delta == 0 {697737 return698738 }@@ -704,7 +744,7 @@ func (s *exportState) moveBucketSelection(delta, width, height int) {
704744 s.ensureCardSelectionVisible(width, height)705745 }706746 707-func (s *exportState) appendSelectedBucketDigit(raw string) {747+func (s *exportModel) appendSelectedBucketDigit(raw string) {708748 if len(s.cards) == 0 {709749 return710750 }@@ -719,7 +759,7 @@ func (s *exportState) appendSelectedBucketDigit(raw string) {
719759 card.Buckets[s.bucketIndex] = card.Buckets[s.bucketIndex]*10 + digit720760 }721761 722-func (s *exportState) backspaceSelectedBucket() {762+func (s *exportModel) backspaceSelectedBucket() {723763 if len(s.cards) == 0 {724764 return725765 }@@ -727,7 +767,7 @@ func (s *exportState) backspaceSelectedBucket() {
727767 card.Buckets[s.bucketIndex] /= 10728768 }729769 730-func (s *exportState) cycleLayout(delta int) {770+func (s *exportModel) cycleLayout(delta int) {731771 index := 0732772 for i, layout := range exportSheetLayouts {733773 if layout == s.values.SheetLayout {@@ -742,14 +782,14 @@ func (s *exportState) cycleLayout(delta int) {
742782 s.values.SheetLayout = exportSheetLayouts[index]743783 }744784 745-func (s *exportState) toggleJSONL() {785+func (s *exportModel) toggleJSONL() {746786 s.values.JSONLEnabled = !s.values.JSONLEnabled747787 if !s.values.JSONLEnabled && s.focus == exportFocusJSONLPath {748788 s.focus = exportFocusCards749789 }750790 }751791 752-func (s *exportState) adjustSelectedBucket(delta int) {792+func (s *exportModel) adjustSelectedBucket(delta int) {753793 if len(s.cards) == 0 {754794 return755795 }@@ -775,7 +815,7 @@ func (c exportGameCard) bucketSeeded(index int) bool {
775815 return true776816 }777817 778-func (s *exportState) cardColumns(width int) int {818+func (s *exportModel) cardColumns(width int) int {779819 for cols := exportCardMaxColumns; cols >= 1; cols-- {780820 if exportCardWidth(width, cols) >= exportCardMinWidth {781821 return cols@@ -784,7 +824,7 @@ func (s *exportState) cardColumns(width int) int {
784824 return 1785825 }786826 787-func (s *exportState) ensureCardSelectionVisible(width, height int) {827+func (s *exportModel) ensureCardSelectionVisible(width, height int) {788828 cols := s.cardColumns(width)789829 if cols <= 0 {790830 return@@ -801,7 +841,7 @@ func (s *exportState) ensureCardSelectionVisible(width, height int) {
801841 s.cardRowOffset = min(max(s.cardRowOffset, 0), maxRows-1)802842 }803843 804-func (s *exportState) visibleCardRows(width, height int) int {844+func (s *exportModel) visibleCardRows(width, height int) int {805845 if len(s.cards) == 0 {806846 return 1807847 }@@ -838,14 +878,14 @@ func canFitCardRows(available, cardHeight, rows int) bool {
838878 return available >= required839879 }840880 841-func (s *exportState) view(width, height int) string {881+func (s *exportModel) view(width, height int) string {842882 settings := s.renderSettings(width)843883 summary := s.renderSummary(width)844884 cards := s.renderCards(width, height)845885 return lipgloss.JoinVertical(lipgloss.Left, settings, summary, cards)846886 }847887 848-func (s *exportState) contentMetrics(width, height int) exportContentMetrics {888+func (s *exportModel) contentMetrics(width, height int) exportContentMetrics {849889 settings := s.settingsRects(width)850890 settingsHeight := settings.TotalHeight851891 summaryHeight := lipgloss.Height(s.renderSummary(width))@@ -887,7 +927,7 @@ func (s *exportState) contentMetrics(width, height int) exportContentMetrics {
887927 }888928 }889929 890-func (s *exportState) settingsRects(width int) exportSettingsRects {930+func (s *exportModel) settingsRects(width int) exportSettingsRects {891931 if width < 72 {892932 return s.stackedSettingsRects(width)893933 }@@ -929,7 +969,7 @@ func (s *exportState) settingsRects(width int) exportSettingsRects {
929969 return row1930970 }931971 932-func (s *exportState) stackedSettingsRects(width int) exportSettingsRects {972+func (s *exportModel) stackedSettingsRects(width int) exportSettingsRects {933973 y := 0934974 inlineHeight := s.inlineFieldHeight(width)935975 headerHeight := s.headerFieldHeight(width)@@ -972,17 +1012,17 @@ func (s *exportState) stackedSettingsRects(width int) exportSettingsRects {
9721012 return rects9731013 }9741014 975-func (s *exportState) inlineFieldHeight(width int) int {1015+func (s *exportModel) inlineFieldHeight(width int) int {9761016 input := s.titleInput9771017 return lipgloss.Height(s.renderInlineInputField("Title", &input, false, max(width, 14)))9781018 }9791019 980-func (s *exportState) headerFieldHeight(width int) int {1020+func (s *exportModel) headerFieldHeight(width int) int {9811021 input := s.headerInput9821022 return lipgloss.Height(s.renderTextareaField("Header", &input, false, max(width, 24)))9831023 }9841024 985-func (s *exportState) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {1025+func (s *exportModel) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {9861026 if len(s.cards) == 0 || cardHeight <= 0 {9871027 return nil9881028 }@@ -1009,7 +1049,7 @@ func (s *exportState) cardRects(width, cardsY, cardWidth, cardHeight, visibleRow
10091049 return layouts10101050 }10111051 1012-func (s *exportState) bucketRectsForCard(index int, rect exportRect) [3]exportRect {1052+func (s *exportModel) bucketRectsForCard(index int, rect exportRect) [3]exportRect {10131053 var rects [3]exportRect10141054 card := s.cards[index]10151055 selected := index == s.cardIndex@@ -1024,7 +1064,7 @@ func (s *exportState) bucketRectsForCard(index int, rect exportRect) [3]exportRe
10241064 return rects10251065 }10261066 1027-func (s *exportState) renderSettings(width int) string {1067+func (s *exportModel) renderSettings(width int) string {10281068 if width < 72 {10291069 return s.renderStackedSettings(width)10301070 }@@ -1065,7 +1105,7 @@ func (s *exportState) renderSettings(width int) string {
10651105 return lipgloss.JoinVertical(lipgloss.Left, rows...)10661106 }10671107 1068-func (s *exportState) renderStackedSettings(width int) string {1108+func (s *exportModel) renderStackedSettings(width int) string {10691109 rows := []string{10701110 s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, width),10711111 s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, width),@@ -1100,7 +1140,7 @@ func (s *exportState) renderStackedSettings(width int) string {
11001140 return lipgloss.JoinVertical(lipgloss.Left, rows...)11011141 }11021142 1103-func (s *exportState) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {1143+func (s *exportModel) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {11041144 border := theme.Current().Border11051145 if focused {11061146 border = theme.Current().Accent@@ -1123,7 +1163,7 @@ func (s *exportState) renderTextareaField(label string, input *textarea.Model, f
11231163 Render(body)11241164 }11251165 1126-func (s *exportState) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {1166+func (s *exportModel) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {11271167 border := theme.Current().Border11281168 if focused {11291169 border = theme.Current().Accent@@ -1148,7 +1188,7 @@ func (s *exportState) renderInlineInputField(label string, input *textinput.Mode
11481188 Render(body)11491189 }11501190 1151-func (s *exportState) renderInlineChoiceField(label, value string, focused bool, width int) string {1191+func (s *exportModel) renderInlineChoiceField(label, value string, focused bool, width int) string {11521192 border := theme.Current().Border11531193 if focused {11541194 border = theme.Current().Accent@@ -1217,7 +1257,7 @@ func onOffLabel(enabled bool) string {
12171257 return "Off"12181258 }12191259 1220-func (s *exportState) renderSummary(width int) string {1260+func (s *exportModel) renderSummary(width int) string {12211261 total := 012221262 for _, card := range s.cards {12231263 for _, count := range card.Buckets {@@ -1245,7 +1285,7 @@ func (s *exportState) renderSummary(width int) string {
12451285 12461286 var bucketNames = [3]string{"Easy", "Medium", "Hard"}12471287 1248-func (s *exportState) renderCards(width, height int) string {1288+func (s *exportModel) renderCards(width, height int) string {12491289 if len(s.cards) == 0 {12501290 return ui.DimItemStyle().Render("No exportable games found.")12511291 }@@ -1278,7 +1318,7 @@ func (s *exportState) renderCards(width, height int) string {
12781318 return content12791319 }12801320 1281-func (s *exportState) renderCard(index, width int) string {1321+func (s *exportModel) renderCard(index, width int) string {12821322 card := s.cards[index]12831323 selected := index == s.cardIndex12841324 @@ -1321,7 +1361,7 @@ func (s *exportState) renderCard(index, width int) string {
13211361 Render(body)13221362 }13231363 1324-func (s *exportState) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {1364+func (s *exportModel) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {13251365 palette := theme.Current()13261366 bg, fg := exportDifficultyColors(bucketIndex, palette)13271367 pairStyle := lipgloss.NewStyle().@@ -1340,28 +1380,28 @@ func (s *exportState) renderDifficultyPair(card exportGameCard, cardSelected boo
13401380 return pair13411381 }13421382 1343-func (s *exportState) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, screenAction, bool) {1383+func (s *exportModel) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, bool) {13441384 mouse := msg.Mouse()13451385 content := s.view(contentWidth, contentHeight)13461386 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)13471387 localX := mouse.X - contentX13481388 localY := mouse.Y - contentY13491389 if localX < 0 || localY < 0 {1350- return nil, nil, false1390+ return nil, false13511391 }13521392 13531393 metrics := s.contentMetrics(contentWidth, contentHeight)13541394 if cmd, handled := s.handleSettingsClick(localX, localY, metrics); handled {1355- return cmd, nil, true1395+ return cmd, true13561396 }13571397 if metrics.SummaryButton.contains(localX, localY) {13581398 s.focus = exportFocusSubmit1359- return s.applyFocus(), exportSubmitAction{}, true1399+ return tea.Batch(s.applyFocus(), exportSubmitCmd), true13601400 }13611401 if cmd, handled := s.handleCardClick(localX, localY, metrics); handled {1362- return cmd, nil, true1402+ return cmd, true13631403 }1364- return nil, nil, false1404+ return nil, false13651405 }13661406 13671407 func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (panelX, panelY, contentX, contentY int) {@@ -1377,7 +1417,7 @@ func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (pa
13771417 return panelX, panelY, contentX, contentY13781418 }13791419 1380-func (s *exportState) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {1420+func (s *exportModel) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {13811421 mouse := msg.Mouse()13821422 content := s.view(contentWidth, contentHeight)13831423 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)@@ -1406,7 +1446,7 @@ func (s *exportState) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, scree
14061446 return true14071447 }14081448 1409-func (s *exportState) clampSelectionToVisibleRows(cols, visibleRows int) {1449+func (s *exportModel) clampSelectionToVisibleRows(cols, visibleRows int) {14101450 if len(s.cards) == 0 || cols <= 0 || visibleRows <= 0 {14111451 return14121452 }@@ -1426,7 +1466,7 @@ func (s *exportState) clampSelectionToVisibleRows(cols, visibleRows int) {
14261466 s.cardIndex = max(index, 0)14271467 }14281468 1429-func (s *exportState) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1469+func (s *exportModel) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {14301470 settings := metrics.Settings14311471 switch {14321472 case settings.Title.contains(localX, localY):@@ -1456,7 +1496,7 @@ func (s *exportState) handleSettingsClick(localX, localY int, metrics exportCont
14561496 }14571497 }14581498 1459-func (s *exportState) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {1499+func (s *exportModel) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {14601500 s.focus = focus14611501 labelWidth := lipgloss.Width(label) + 114621502 cursorPos := max(localX-rect.X-2-labelWidth, 0)@@ -1464,7 +1504,7 @@ func (s *exportState) focusTextInput(focus exportFocus, input *textinput.Model,
14641504 return s.applyFocus()14651505 }14661506 1467-func (s *exportState) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {1507+func (s *exportModel) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {14681508 s.focus = exportFocusHeader14691509 line := max(localY-rect.Y-2, 0)14701510 col := max(localX-rect.X-2, 0)@@ -1478,7 +1518,7 @@ func (s *exportState) focusTextarea(rect exportRect, localX, localY int) tea.Cmd
14781518 return s.applyFocus()14791519 }14801520 1481-func (s *exportState) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1521+func (s *exportModel) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {14821522 if len(s.cards) == 0 {14831523 return nil, false14841524 }@@ -1496,7 +1536,7 @@ func (s *exportState) handleCardClick(localX, localY int, metrics exportContentM
14961536 return nil, false14971537 }14981538 1499-func (s *exportState) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {1539+func (s *exportModel) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {15001540 s.focus = exportFocusCards15011541 s.cardIndex = cardIndex15021542 s.bucketIndex = min(max(bucketIndex, 0), len(bucketNames)-1)@@ -1569,7 +1609,7 @@ func truncateLabel(s string, width int) string {
15691609 return string(runes[:width-1]) + "…"15701610 }15711611 1572-func (s exportState) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {1612+func (s exportModel) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {15731613 sync := s15741614 sync.syncValuesFromInputs()15751615 @@ -1594,7 +1634,7 @@ func (s exportState) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {
15941634 return spec, cfg15951635 }15961636 1597-func (s *exportState) syncValuesFromInputs() {1637+func (s *exportModel) syncValuesFromInputs() {15981638 s.values.Title = s.titleInput.Value()15991639 s.values.Header = s.headerInput.Value()16001640 s.values.Advert = s.advertInput.Value()@@ -62,7 +62,7 @@ func TestHandleExportCompleteSuccessShowsSuccessNotice(t *testing.T) {
6262 state: exportRunningView,6363 width: 100,6464 height: 30,65- export: exportState{65+ export: exportModel{6666 jobID: 7,6767 initialized: true,6868 values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),@@ -102,7 +102,7 @@ func TestHandleExportCompleteSuccessIncludesJSONLWhenPresent(t *testing.T) {
102102 state: exportRunningView,103103 width: 100,104104 height: 30,105- export: exportState{105+ export: exportModel{106106 jobID: 8,107107 initialized: true,108108 values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),@@ -133,7 +133,7 @@ func TestHandleExportCompleteFailureShowsErrorNotice(t *testing.T) {
133133 state: exportRunningView,134134 width: 100,135135 height: 30,136- export: exportState{136+ export: exportModel{137137 jobID: 9,138138 initialized: true,139139 values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),@@ -311,12 +311,15 @@ func TestExportMouseClickOnSummaryButtonSubmits(t *testing.T) {
311311 X: contentX + metrics.Width - buttonWidth + 1,312312 Y: contentY + metrics.SettingsHeight,313313 }314- _, action, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)314+ cmd, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)315315 if !handled {316316 t.Fatal("expected export button click to be handled")317317 }318- if _, ok := action.(exportSubmitAction); !ok {319- t.Fatalf("action = %T, want exportSubmitAction", action)318+ if state.focus != exportFocusSubmit {319+ t.Fatalf("focus = %v, want exportFocusSubmit after submit button click", state.focus)320+ }321+ if cmd == nil {322+ t.Fatal("expected submit button click to return a non-nil command")320323 }321324 }322325 @@ -350,7 +353,7 @@ func TestExportMouseClickFocusesTextFields(t *testing.T) {
350353 X: contentX + tt.rect.X + min(tt.rect.W-2, 14),351354 Y: contentY + tt.rect.Y + 1,352355 }353- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)356+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)354357 if !handled {355358 t.Fatalf("%s click was not handled", tt.name)356359 }@@ -381,12 +384,12 @@ func TestExportMouseClickRepositionsTextCursor(t *testing.T) {
381384 X: contentX + metrics.Settings.Title.X + 20,382385 Y: contentY + metrics.Settings.Title.Y + 1,383386 }384- _, _, handled := state.handleMouseClick(first, screenWidth, screenHeight, contentWidth, contentHeight)387+ _, handled := state.handleMouseClick(first, screenWidth, screenHeight, contentWidth, contentHeight)385388 if !handled {386389 t.Fatal("expected first title click to be handled")387390 }388391 firstPos := state.titleInput.Position()389- _, _, handled = state.handleMouseClick(second, screenWidth, screenHeight, contentWidth, contentHeight)392+ _, handled = state.handleMouseClick(second, screenWidth, screenHeight, contentWidth, contentHeight)390393 if !handled {391394 t.Fatal("expected second title click to be handled")392395 }@@ -410,7 +413,7 @@ func TestExportMouseClickFocusesHeaderTextarea(t *testing.T) {
410413 X: contentX + metrics.Settings.Header.X + 18,411414 Y: contentY + metrics.Settings.Header.Y + 4,412415 }413- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)416+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)414417 if !handled {415418 t.Fatal("expected header click to be handled")416419 }@@ -439,7 +442,7 @@ func TestExportMouseClickTogglesJSONLImmediately(t *testing.T) {
439442 X: contentX + metrics.Settings.JSONL.X + 4,440443 Y: contentY + metrics.Settings.JSONL.Y + 1,441444 }442- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)445+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)443446 if !handled {444447 t.Fatal("expected JSONL click to be handled")445448 }@@ -465,7 +468,7 @@ func TestExportMouseClickCyclesLayoutImmediately(t *testing.T) {
465468 X: contentX + metrics.Settings.Layout.X + 5,466469 Y: contentY + metrics.Settings.Layout.Y + 1,467470 }468- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)471+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)469472 if !handled {470473 t.Fatal("expected layout click to be handled")471474 }@@ -490,7 +493,7 @@ func TestExportMouseClickSelectsCardBucketWithoutChangingCount(t *testing.T) {
490493 X: contentX + target.Buckets[1].X + 1,491494 Y: contentY + target.Buckets[1].Y,492495 }493- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)496+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)494497 if !handled {495498 t.Fatal("expected card bucket click to be handled")496499 }@@ -19,7 +19,6 @@ import (
1919 "charm.land/bubbles/v2/list"2020 "charm.land/bubbles/v2/spinner"2121 "charm.land/bubbles/v2/table"22- "charm.land/bubbles/v2/textarea"2322 "charm.land/bubbles/v2/textinput"2423 "charm.land/bubbles/v2/viewport"2524 tea "charm.land/bubbletea/v2"@@ -146,26 +145,6 @@ type themeState struct {
146145 previous string147146 }148147 149-type exportState struct {150- initialized bool151- focus exportFocus152- values exportFormValues153- titleInput textinput.Model154- headerInput textarea.Model155- advertInput textinput.Model156- volumeInput textinput.Model157- seedInput textinput.Model158- pdfPathInput textinput.Model159- jsonlPathInput textinput.Model160- cards []exportGameCard161- cardIndex int162- bucketIndex int163- cardRowOffset int164- running bool165- jobID int64166- cancel context.CancelFunc167-}168-169148 type debugState struct {170149 enabled bool171150 renderer *glamour.TermRenderer@@ -216,7 +195,7 @@ type model struct {
216195 help helpState217196 stats statsState218197 theme themeState219- export exportState198+ export exportModel220199 debug debugState221200 notice noticeState222201 @@ -1,107 +1,184 @@
11 package app22 3-import tea "charm.land/bubbletea/v2"3+import (4+ "time"45 5-type screenAction interface{ isScreenAction() }6+ "github.com/FelineStateMachine/puzzletea/theme"7+ "github.com/FelineStateMachine/puzzletea/ui"8+9+ tea "charm.land/bubbletea/v2"10+)11+12+type screenAction interface {13+ applyToModel(m model) (model, tea.Cmd)14+}615 716 type openPlayMenuAction struct{}817 9-func (openPlayMenuAction) isScreenAction() {}18+func (a openPlayMenuAction) applyToModel(m model) (model, tea.Cmd) {19+ m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))20+ m.state = playMenuView21+ m = m.clearNotice()22+ return m.resizeActiveScreen(), nil23+}1024 1125 type openStatsAction struct{}1226 13-func (openStatsAction) isScreenAction() {}27+func (a openStatsAction) applyToModel(m model) (model, tea.Cmd) {28+ return asModel(m.handleStatsEnter())29+}1430 1531 type openOptionsMenuAction struct{}1632 17-func (openOptionsMenuAction) isScreenAction() {}33+func (a openOptionsMenuAction) applyToModel(m model) (model, tea.Cmd) {34+ m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)35+ m.state = optionsMenuView36+ m = m.clearNotice()37+ return m.resizeActiveScreen(), nil38+}1839 1940 type quitAction struct{}2041 21-func (quitAction) isScreenAction() {}42+func (a quitAction) applyToModel(m model) (model, tea.Cmd) {43+ return m, tea.Quit44+}2245 2346 type openGameSelectAction struct{}2447 25-func (openGameSelectAction) isScreenAction() {}48+func (a openGameSelectAction) applyToModel(m model) (model, tea.Cmd) {49+ m.state = gameSelectView50+ m = m.updateCategoryDetailViewport()51+ m = m.clearNotice()52+ return m.resizeActiveScreen(), nil53+}2654 2755 type openContinueAction struct{}2856 29-func (openContinueAction) isScreenAction() {}57+func (a openContinueAction) applyToModel(m model) (model, tea.Cmd) {58+ m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height)59+ m.state = continueView60+ m = m.clearNotice()61+ return m.resizeActiveScreen(), nil62+}3063 3164 type openDailyAction struct{}3265 33-func (openDailyAction) isScreenAction() {}66+func (a openDailyAction) applyToModel(m model) (model, tea.Cmd) {67+ return asModel(m.handleDailyPuzzle())68+}3469 35-type openWeeklyAction struct{}70+type openExportAction struct{}3671 37-func (openWeeklyAction) isScreenAction() {}72+func (a openExportAction) applyToModel(m model) (model, tea.Cmd) {73+ return asModel(m.handleExportEnter())74+}3875 39-type openSeedInputAction struct{}76+type openWeeklyAction struct{}4077 41-func (openSeedInputAction) isScreenAction() {}78+func (a openWeeklyAction) applyToModel(m model) (model, tea.Cmd) {79+ return asModel(m.enterWeeklyView())80+}4281 43-type openExportAction struct{}82+type openSeedInputAction struct{}4483 45-func (openExportAction) isScreenAction() {}84+func (a openSeedInputAction) applyToModel(m model) (model, tea.Cmd) {85+ return m.enterSeedInputView()86+}4687 4788 type backAction struct {4889 target viewState4990 }5091 51-func (backAction) isScreenAction() {}92+func (a backAction) applyToModel(m model) (model, tea.Cmd) {93+ if m.state == themeSelectView {94+ _ = theme.Apply(m.theme.previous)95+ }96+ m.state = a.target97+ return m.resizeActiveScreen(), nil98+}5299 53100 type gameSelectEnterAction struct{}54101 55-func (gameSelectEnterAction) isScreenAction() {}102+func (a gameSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {103+ return asModel(m.handleGameSelectEnter())104+}56105 57106 type modeSelectEnterAction struct{}58107 59-func (modeSelectEnterAction) isScreenAction() {}108+func (a modeSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {109+ return asModel(m.handleModeSelectEnter())110+}60111 61112 type continueEnterAction struct{}62113 63-func (continueEnterAction) isScreenAction() {}114+func (a continueEnterAction) applyToModel(m model) (model, tea.Cmd) {115+ return asModel(m.handleContinueEnter())116+}64117 65118 type weeklyShiftAction struct {66119 delta int67120 }68121 69-func (weeklyShiftAction) isScreenAction() {}122+func (a weeklyShiftAction) applyToModel(m model) (model, tea.Cmd) {123+ m = m.moveWeeklyWeek(a.delta)124+ return m, nil125+}70126 71127 type weeklyEnterAction struct{}72128 73-func (weeklyEnterAction) isScreenAction() {}129+func (a weeklyEnterAction) applyToModel(m model) (model, tea.Cmd) {130+ return asModel(m.handleWeeklyEnter())131+}74132 75133 type helpSelectEnterAction struct{}76134 77-func (helpSelectEnterAction) isScreenAction() {}135+func (a helpSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {136+ return asModel(m.handleHelpSelectEnter())137+}78138 79139 type openThemeSelectAction struct{}80140 81-func (openThemeSelectAction) isScreenAction() {}141+func (a openThemeSelectAction) applyToModel(m model) (model, tea.Cmd) {142+ return asModel(m.handleThemeEnter())143+}82144 83145 type openHelpSelectAction struct{}84146 85-func (openHelpSelectAction) isScreenAction() {}147+func (a openHelpSelectAction) applyToModel(m model) (model, tea.Cmd) {148+ m.help.selectList = ui.InitList(gameCategoryItems, "How to Play")149+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)150+ m.help.selectList.SetSize(listWidth, listHeight)151+ m.state = helpSelectView152+ m = m.clearNotice()153+ return m.resizeActiveScreen(), nil154+}86155 87156 type previewThemeAction struct {88157 name string89158 }90159 91-func (previewThemeAction) isScreenAction() {}160+func (a previewThemeAction) applyToModel(m model) (model, tea.Cmd) {161+ _ = theme.Apply(a.name)162+ ui.UpdateThemeListStyles(&m.theme.list)163+ return m, nil164+}92165 93166 type confirmThemeAction struct{}94167 95-func (confirmThemeAction) isScreenAction() {}168+func (a confirmThemeAction) applyToModel(m model) (model, tea.Cmd) {169+ return asModel(m.handleThemeConfirm())170+}96171 97172 type seedConfirmAction struct{}98173 99-func (seedConfirmAction) isScreenAction() {}174+func (a seedConfirmAction) applyToModel(m model) (model, tea.Cmd) {175+ return asModel(m.handleSeedConfirm())176+}100177 178+// exportSubmitAction is a pure tea.Msg emitted by exportSubmitCmd.179+// It is handled in the root Update() and does not implement screenAction.101180 type exportSubmitAction struct{}102181 103-func (exportSubmitAction) isScreenAction() {}104-105182 type screenModel interface {106183 State() viewState107184 Resize(width, height int) screenModel@@ -110,101 +187,60 @@ type screenModel interface {
110187 Apply(m model) model111188 }112189 190+type screenFactory func(m model) screenModel191+192+var screenRegistry = map[viewState]screenFactory{193+ mainMenuView: func(m model) screenModel {194+ return mainMenuScreen{width: m.width, height: m.height, menu: m.nav.mainMenu}195+ },196+ playMenuView: func(m model) screenModel {197+ return playMenuScreen{width: m.width, height: m.height, menu: m.nav.playMenu}198+ },199+ optionsMenuView: func(m model) screenModel {200+ return optionsMenuScreen{width: m.width, height: m.height, menu: m.nav.optionsMenu}201+ },202+ seedInputView: func(m model) screenModel {203+ return seedInputScreen{width: m.width, height: m.height, seed: m.seed}204+ },205+ gameSelectView: func(m model) screenModel {206+ return gameSelectScreen{width: m.width, height: m.height, list: m.nav.gameSelectList, detail: m.nav.categoryDetail}207+ },208+ modeSelectView: func(m model) screenModel {209+ return modeSelectScreen{width: m.width, height: m.height, entry: m.nav.selectedCategory, list: m.nav.modeSelectList}210+ },211+ exportView: func(m model) screenModel {212+ return exportScreen{width: m.width, height: m.height, export: m.export}213+ },214+ continueView: func(m model) screenModel {215+ return continueScreen{width: m.width, height: m.height, cont: m.cont}216+ },217+ weeklyView: func(m model) screenModel {218+ return weeklyScreen{width: m.width, height: m.height, weekly: m.weekly}219+ },220+ helpSelectView: func(m model) screenModel {221+ return helpSelectScreen{width: m.width, height: m.height, help: m.help}222+ },223+ helpDetailView: func(m model) screenModel {224+ return helpDetailScreen{width: m.width, height: m.height, help: m.help}225+ },226+ statsView: func(m model) screenModel {227+ return statsScreen{width: m.width, height: m.height, stats: m.stats}228+ },229+ themeSelectView: func(m model) screenModel {230+ return themeSelectScreen{width: m.width, height: m.height, theme: m.theme}231+ },232+ generatingView: func(m model) screenModel {233+ return generatingScreen{width: m.width, height: m.height, spinner: m.spinner}234+ },235+ exportRunningView: func(m model) screenModel {236+ return exportRunningScreen{width: m.width, height: m.height, spinner: m.spinner}237+ },238+}239+113240 func (m model) activeScreen() screenModel {114- switch m.state {115- case mainMenuView:116- return mainMenuScreen{117- width: m.width,118- height: m.height,119- menu: m.nav.mainMenu,120- }121- case playMenuView:122- return playMenuScreen{123- width: m.width,124- height: m.height,125- menu: m.nav.playMenu,126- }127- case optionsMenuView:128- return optionsMenuScreen{129- width: m.width,130- height: m.height,131- menu: m.nav.optionsMenu,132- }133- case seedInputView:134- return seedInputScreen{135- width: m.width,136- height: m.height,137- seed: m.seed,138- }139- case gameSelectView:140- return gameSelectScreen{141- width: m.width,142- height: m.height,143- list: m.nav.gameSelectList,144- detail: m.nav.categoryDetail,145- }146- case modeSelectView:147- return modeSelectScreen{148- width: m.width,149- height: m.height,150- entry: m.nav.selectedCategory,151- list: m.nav.modeSelectList,152- }153- case exportView:154- return exportScreen{155- width: m.width,156- height: m.height,157- export: m.export,158- }159- case continueView:160- return continueScreen{161- width: m.width,162- height: m.height,163- cont: m.cont,164- }165- case weeklyView:166- return weeklyScreen{167- width: m.width,168- height: m.height,169- weekly: m.weekly,170- }171- case helpSelectView:172- return helpSelectScreen{173- width: m.width,174- height: m.height,175- help: m.help,176- }177- case helpDetailView:178- return helpDetailScreen{179- width: m.width,180- height: m.height,181- help: m.help,182- }183- case statsView:184- return statsScreen{185- width: m.width,186- height: m.height,187- stats: m.stats,188- }189- case themeSelectView:190- return themeSelectScreen{191- width: m.width,192- height: m.height,193- theme: m.theme,194- }195- case generatingView:196- return generatingScreen{197- width: m.width,198- height: m.height,199- spinner: m.spinner,200- }201- case exportRunningView:202- return exportRunningScreen{203- width: m.width,204- height: m.height,205- spinner: m.spinner,206- }207- default:241+ factory, ok := screenRegistry[m.state]242+ if !ok {208243 return nil209244 }245+ return factory(m)210246 }@@ -1,12 +1,8 @@
11 package app22 33 import (4- "time"5-64 "github.com/FelineStateMachine/puzzletea/game"75 "github.com/FelineStateMachine/puzzletea/store"8- "github.com/FelineStateMachine/puzzletea/theme"9- "github.com/FelineStateMachine/puzzletea/ui"106 117 "charm.land/bubbles/v2/key"128 tea "charm.land/bubbletea/v2"@@ -22,6 +18,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2218 return next, cmd2319 case exportCompleteMsg:2420 return m.handleExportComplete(msg)21+ case exportSubmitAction:22+ return asModel(m.handleExportSubmit())23+ case backAction:24+ return msg.applyToModel(m)2525 case tea.WindowSizeMsg:2626 m = m.handleWindowSize(msg)2727 if m.state != gameView {@@ -162,80 +162,7 @@ func (m model) activeSpawnReturnState() viewState {
162162 }163163 164164 func (m model) handleScreenAction(action screenAction) (model, tea.Cmd) {165- switch action := action.(type) {166- case openPlayMenuAction:167- m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))168- m.state = playMenuView169- m = m.clearNotice()170- return m.resizeActiveScreen(), nil171- case openStatsAction:172- return asModel(m.handleStatsEnter())173- case openOptionsMenuAction:174- m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)175- m.state = optionsMenuView176- m = m.clearNotice()177- return m.resizeActiveScreen(), nil178- case quitAction:179- return m, tea.Quit180- case openGameSelectAction:181- m.state = gameSelectView182- m = m.updateCategoryDetailViewport()183- m = m.clearNotice()184- return m.resizeActiveScreen(), nil185- case openContinueAction:186- m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height)187- m.state = continueView188- m = m.clearNotice()189- return m.resizeActiveScreen(), nil190- case openDailyAction:191- return asModel(m.handleDailyPuzzle())192- case openExportAction:193- return asModel(m.handleExportEnter())194- case openWeeklyAction:195- return asModel(m.enterWeeklyView())196- case openSeedInputAction:197- return m.enterSeedInputView()198- case backAction:199- if m.state == themeSelectView {200- _ = theme.Apply(m.theme.previous)201- }202- m.state = action.target203- return m.resizeActiveScreen(), nil204- case gameSelectEnterAction:205- return asModel(m.handleGameSelectEnter())206- case modeSelectEnterAction:207- return asModel(m.handleModeSelectEnter())208- case continueEnterAction:209- return asModel(m.handleContinueEnter())210- case weeklyShiftAction:211- m = m.moveWeeklyWeek(action.delta)212- return m, nil213- case weeklyEnterAction:214- return asModel(m.handleWeeklyEnter())215- case helpSelectEnterAction:216- return asModel(m.handleHelpSelectEnter())217- case openThemeSelectAction:218- return asModel(m.handleThemeEnter())219- case openHelpSelectAction:220- m.help.selectList = ui.InitList(gameCategoryItems, "How to Play")221- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)222- m.help.selectList.SetSize(listWidth, listHeight)223- m.state = helpSelectView224- m = m.clearNotice()225- return m.resizeActiveScreen(), nil226- case previewThemeAction:227- _ = theme.Apply(action.name)228- ui.UpdateThemeListStyles(&m.theme.list)229- return m, nil230- case confirmThemeAction:231- return asModel(m.handleThemeConfirm())232- case seedConfirmAction:233- return asModel(m.handleSeedConfirm())234- case exportSubmitAction:235- return asModel(m.handleExportSubmit())236- default:237- return m, nil238- }165+ return action.applyToModel(m)239166 }240167 241168 func asModel(next tea.Model, cmd tea.Cmd) (model, tea.Cmd) {