@@ -10,8 +10,8 @@ import (
1010 "strings"1111 1212 "github.com/FelineStateMachine/puzzletea/config"13+ "github.com/FelineStateMachine/puzzletea/export/pack"1314 puzzlegame "github.com/FelineStateMachine/puzzletea/game"14- "github.com/FelineStateMachine/puzzletea/packexport"1515 "github.com/FelineStateMachine/puzzletea/puzzle"1616 "github.com/FelineStateMachine/puzzletea/theme"1717 "github.com/FelineStateMachine/puzzletea/ui"@@ -24,6 +24,24 @@ import (
2424 "charm.land/lipgloss/v2"2525 )2626 27+type exportModel struct {28+ focus exportFocus29+ values exportFormValues30+ titleInput textinput.Model31+ headerInput textarea.Model32+ advertInput textinput.Model33+ volumeInput textinput.Model34+ seedInput textinput.Model35+ pdfPathInput textinput.Model36+ jsonlPathInput textinput.Model37+ cards []exportGameCard38+ cardIndex int39+ bucketIndex int40+ cardRowOffset int41+ width int42+ height int43+}44+2745 type exportFocus int2846 2947 const (@@ -109,35 +127,27 @@ type exportCompleteMsg struct {
109127 err error110128 }111129 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-}130+131+132+var (133+ exportSubmitCmd = func() tea.Msg { return exportSubmitAction{} }134+ exportBackCmd = func() tea.Msg { return backAction{target: mainMenuView} }135+)128136 129-func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {137+138+139+func (s *exportModel) Update(msg tea.Msg) (exportModel, tea.Cmd) {130140 if clickMsg, ok := msg.(tea.MouseClickMsg); ok {131141 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, action142+ if cmd, handled := s.handleMouseClick(clickMsg, s.width, s.height, contentWidth, contentHeight); handled {143+ return *s, cmd134144 }135- return s, nil, nil145+ return *s, nil136146 }137147 if wheelMsg, ok := msg.(tea.MouseWheelMsg); ok {138148 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, nil149+ if handled := s.handleMouseWheel(wheelMsg, s.width, s.height, contentWidth, contentHeight); handled {150+ return *s, nil141151 }142152 }143153 @@ -145,39 +155,66 @@ func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
145155 if ok {146156 switch {147157 case key.Matches(keyMsg, rootKeys.Escape):148- return s, nil, backAction{target: mainMenuView}158+ return *s, exportBackCmd149159 case keyMsg.String() == "tab":150- cmd := s.export.moveFocus(1)151- return s, cmd, nil160+ cmd := s.moveFocus(1)161+ return *s, cmd152162 case keyMsg.String() == "shift+tab":153- cmd := s.export.moveFocus(-1)154- return s, cmd, nil163+ cmd := s.moveFocus(-1)164+ return *s, cmd155165 case key.Matches(keyMsg, rootKeys.Enter):156- if s.export.focus == exportFocusSubmit {157- return s, nil, exportSubmitAction{}166+ if s.focus == exportFocusSubmit {167+ return *s, exportSubmitCmd158168 }159- if s.export.focus == exportFocusHeader {169+ if s.focus == exportFocusHeader {160170 break161171 }162- if s.export.focus == exportFocusLayout {163- s.export.cycleLayout(1)164- return s, nil, nil172+ if s.focus == exportFocusLayout {173+ s.cycleLayout(1)174+ return *s, nil165175 }166- if s.export.focus == exportFocusJSONLToggle {167- s.export.toggleJSONL()168- return s, nil, nil176+ if s.focus == exportFocusJSONLToggle {177+ s.toggleJSONL()178+ return *s, nil169179 }170- cmd := s.export.moveFocus(1)171- return s, cmd, nil180+ cmd := s.moveFocus(1)181+ return *s, cmd172182 }173183 174184 contentWidth, contentHeight := exportContentBounds(s.width, s.height)175- if handled := s.export.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {176- return s, nil, nil185+ if handled := s.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {186+ return *s, nil177187 }178188 }179189 180- cmd := s.export.updateFocusedInput(msg)190+ cmd := s.updateFocusedInput(msg)191+ return *s, cmd192+}193+194+type exportScreen struct {195+ width int196+ height int197+ export exportModel198+ 199+ running bool200+ jobID int64201+ cancel context.CancelFunc202+}203+204+func (s exportScreen) Resize(width, height int) screenModel {205+ s.width = width206+ s.height = height207+ s.export.width = width208+ s.export.height = height209+ contentWidth, contentHeight := exportContentBounds(width, height)210+ s.export.resize(contentWidth)211+ s.export.ensureCardSelectionVisible(contentWidth, contentHeight)212+ return s213+}214+215+func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {216+ next, cmd := s.export.Update(msg)217+ s.export = next181218 return s, cmd, nil182219 }183220 @@ -193,20 +230,12 @@ func (s exportScreen) View(notice noticeState) string {
193230 )194231 }195232 196-func (s exportScreen) Apply(m model) model {197- m.state = exportView198- m.export = s.export199- return m200-}201-202233 type exportRunningScreen struct {203234 width int204235 height int205236 spinner spinner.Model206237 }207238 208-func (s exportRunningScreen) State() viewState { return exportRunningView }209-210239 func (s exportRunningScreen) Resize(width, height int) screenModel {211240 s.width = width212241 s.height = height@@ -225,26 +254,37 @@ func (s exportRunningScreen) View(notice noticeState) string {
225254 return ui.CenterView(s.width, s.height, box)226255 }227256 228-func (s exportRunningScreen) Apply(m model) model {229- m.state = exportRunningView230- m.spinner = s.spinner231- return m257+func (m model) activeExportScreen() (exportScreen, bool) {258+ s, ok := m.screens[exportView].(exportScreen)259+ return s, ok232260 }233261 234-func (m model) handleExportEnter() (tea.Model, tea.Cmd) {235- if !m.export.initialized {262+func (m model) handleExportEnter() (model, tea.Cmd) {263+ if m.screens == nil {264+ m.screens = make(map[viewState]screenModel)265+ }266+ if _, exists := m.screens[exportView]; !exists {236267 spec := m.initialExportSpec()237- m.export = buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), m.width)268+ es := exportScreen{269+ width: m.width,270+ height: m.height,271+ export: buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), m.width),272+ }273+ m.screens[exportView] = es.Resize(m.width, m.height)238274 } else {239- m.export.resize(m.width)275+ m.screens[exportView] = m.screens[exportView].Resize(m.width, m.height)240276 }241277 m.state = exportView242278 m = m.clearNotice()243- return m.resizeActiveScreen(), nil279+ return m, nil244280 }245281 246-func (m model) handleExportSubmit() (tea.Model, tea.Cmd) {247- spec, cfg := m.export.toSpecAndConfig()282+func (m model) handleExportSubmit() (model, tea.Cmd) {283+ es, ok := m.activeExportScreen()284+ if !ok {285+ return m, nil286+ }287+ spec, cfg := es.export.toSpecAndConfig()248288 if err := packexport.ValidateSpec(spec); err != nil {249289 return m.setErrorf("Could not start export: %v", err), nil250290 }@@ -258,17 +298,18 @@ func (m model) handleExportSubmit() (tea.Model, tea.Cmd) {
258298 return m, cmd259299 }260300 261-func (m model) handleExportComplete(msg exportCompleteMsg) (tea.Model, tea.Cmd) {262- if msg.jobID != m.export.jobID {301+func (m model) handleExportComplete(msg exportCompleteMsg) (model, tea.Cmd) {302+ es, ok := m.activeExportScreen()303+ if !ok || msg.jobID != es.jobID {263304 return m, nil264305 }265306 266- m.export.running = false267- if m.export.cancel != nil {268- m.export.cancel()269- m.export.cancel = nil307+ if es.cancel != nil {308+ es.cancel()309+ es.cancel = nil270310 }271-311+ es.running = false312+ m.screens[exportView] = es272313 m.state = exportView273314 274315 if msg.err != nil {@@ -283,25 +324,33 @@ func (m model) handleExportComplete(msg exportCompleteMsg) (tea.Model, tea.Cmd)
283324 }284325 285326 func (m *model) cancelActiveExport() {286- if m.export.cancel != nil {287- m.export.cancel()288- m.export.cancel = nil327+ es, ok := m.screens[exportView].(exportScreen)328+ if !ok {329+ return289330 }290- if m.export.running {291- m.export.jobID++331+ if es.cancel != nil {332+ es.cancel()333+ es.cancel = nil292334 }293- m.export.running = false335+ if es.running {336+ es.jobID++337+ }338+ es.running = false339+ m.screens[exportView] = es294340 }295341 296342 func (m *model) startExport(spec packexport.Spec) tea.Cmd {297343 m.cancelActiveExport()298- m.export.jobID++299- jobID := m.export.jobID344+ es, _ := m.screens[exportView].(exportScreen)345+ es.jobID++346+ jobID := es.jobID300347 ctx, cancel := context.WithCancel(context.Background())301- m.export.cancel = cancel302- m.export.running = true348+ es.cancel = cancel349+ es.running = true350+ m.screens[exportView] = es303351 m.state = exportRunningView304352 *m = m.clearNotice()353+ *m = m.initScreen(exportRunningView)305354 return tea.Batch(m.spinner.Tick, exportCmd(ctx, jobID, spec))306355 }307356 @@ -312,13 +361,13 @@ func exportCmd(ctx context.Context, jobID int64, spec packexport.Spec) tea.Cmd {
312361 }313362 }314363 315-func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportState {364+func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportModel {316365 contentWidth, _ := exportContentBounds(width, 0)317- state := exportState{318- initialized: true,319- focus: exportFocusCards,320- values: values,321- cards: cards,366+ state := exportModel{367+ focus: exportFocusCards,368+ values: values,369+ cards: cards,370+ width: width,322371 }323372 state.initInputs(contentWidth)324373 return state@@ -444,7 +493,7 @@ func bucketIndexForMode(index, total int) int {
444493 return index * 3 / total445494 }446495 447-func (s *exportState) initInputs(width int) {496+func (s *exportModel) initInputs(width int) {448497 s.titleInput = newExportInput(s.values.Title, "Sampler title")449498 s.headerInput = newExportTextarea(s.values.Header, "Short intro")450499 s.advertInput = newExportInput(s.values.Advert, "Footer copy")@@ -511,7 +560,7 @@ func styleExportTextarea(input *textarea.Model) {
511560 input.SetStyles(styles)512561 }513562 514-func (s *exportState) resize(width int) {563+func (s *exportModel) resize(width int) {515564 styleExportInput(&s.titleInput)516565 styleExportTextarea(&s.headerInput)517566 styleExportInput(&s.advertInput)@@ -531,7 +580,7 @@ func (s *exportState) resize(width int) {
531580 s.jsonlPathInput.SetWidth(max(26, min(width-20, 72)))532581 }533582 534-func (s *exportState) visibleFocuses() []exportFocus {583+func (s *exportModel) visibleFocuses() []exportFocus {535584 focuses := []exportFocus{536585 exportFocusTitle,537586 exportFocusAdvert,@@ -549,7 +598,7 @@ func (s *exportState) visibleFocuses() []exportFocus {
549598 return focuses550599 }551600 552-func (s *exportState) moveFocus(delta int) tea.Cmd {601+func (s *exportModel) moveFocus(delta int) tea.Cmd {553602 focuses := s.visibleFocuses()554603 current := 0555604 for i, focus := range focuses {@@ -563,10 +612,16 @@ func (s *exportState) moveFocus(delta int) tea.Cmd {
563612 next += len(focuses)564613 }565614 s.focus = focuses[next]615+ switch s.focus {616+ case exportFocusPDFPath:617+ s.pdfPathInput.SetCursor(runeIdxBeforeExt(s.values.PDFOutputPath))618+ case exportFocusJSONLPath:619+ s.jsonlPathInput.SetCursor(runeIdxBeforeExt(s.values.JSONLOutputPath))620+ }566621 return s.applyFocus()567622 }568623 569-func (s *exportState) applyFocus() tea.Cmd {624+func (s *exportModel) applyFocus() tea.Cmd {570625 s.titleInput.Blur()571626 s.headerInput.Blur()572627 s.advertInput.Blur()@@ -595,7 +650,7 @@ func (s *exportState) applyFocus() tea.Cmd {
595650 }596651 }597652 598-func (s *exportState) updateFocusedInput(msg tea.Msg) tea.Cmd {653+func (s *exportModel) updateFocusedInput(msg tea.Msg) tea.Cmd {599654 var cmd tea.Cmd600655 switch s.focus {601656 case exportFocusTitle:@@ -623,7 +678,7 @@ func (s *exportState) updateFocusedInput(msg tea.Msg) tea.Cmd {
623678 return cmd624679 }625680 626-func (s *exportState) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {681+func (s *exportModel) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {627682 switch s.focus {628683 case exportFocusLayout:629684 switch msg.String() {@@ -692,7 +747,7 @@ func (s *exportState) handleNavigationKey(msg tea.KeyPressMsg, width, height int
692747 return false693748 }694749 695-func (s *exportState) moveBucketSelection(delta, width, height int) {750+func (s *exportModel) moveBucketSelection(delta, width, height int) {696751 if len(s.cards) == 0 || delta == 0 {697752 return698753 }@@ -704,7 +759,7 @@ func (s *exportState) moveBucketSelection(delta, width, height int) {
704759 s.ensureCardSelectionVisible(width, height)705760 }706761 707-func (s *exportState) appendSelectedBucketDigit(raw string) {762+func (s *exportModel) appendSelectedBucketDigit(raw string) {708763 if len(s.cards) == 0 {709764 return710765 }@@ -719,7 +774,7 @@ func (s *exportState) appendSelectedBucketDigit(raw string) {
719774 card.Buckets[s.bucketIndex] = card.Buckets[s.bucketIndex]*10 + digit720775 }721776 722-func (s *exportState) backspaceSelectedBucket() {777+func (s *exportModel) backspaceSelectedBucket() {723778 if len(s.cards) == 0 {724779 return725780 }@@ -727,7 +782,7 @@ func (s *exportState) backspaceSelectedBucket() {
727782 card.Buckets[s.bucketIndex] /= 10728783 }729784 730-func (s *exportState) cycleLayout(delta int) {785+func (s *exportModel) cycleLayout(delta int) {731786 index := 0732787 for i, layout := range exportSheetLayouts {733788 if layout == s.values.SheetLayout {@@ -742,14 +797,14 @@ func (s *exportState) cycleLayout(delta int) {
742797 s.values.SheetLayout = exportSheetLayouts[index]743798 }744799 745-func (s *exportState) toggleJSONL() {800+func (s *exportModel) toggleJSONL() {746801 s.values.JSONLEnabled = !s.values.JSONLEnabled747802 if !s.values.JSONLEnabled && s.focus == exportFocusJSONLPath {748803 s.focus = exportFocusCards749804 }750805 }751806 752-func (s *exportState) adjustSelectedBucket(delta int) {807+func (s *exportModel) adjustSelectedBucket(delta int) {753808 if len(s.cards) == 0 {754809 return755810 }@@ -775,7 +830,7 @@ func (c exportGameCard) bucketSeeded(index int) bool {
775830 return true776831 }777832 778-func (s *exportState) cardColumns(width int) int {833+func (s *exportModel) cardColumns(width int) int {779834 for cols := exportCardMaxColumns; cols >= 1; cols-- {780835 if exportCardWidth(width, cols) >= exportCardMinWidth {781836 return cols@@ -784,7 +839,7 @@ func (s *exportState) cardColumns(width int) int {
784839 return 1785840 }786841 787-func (s *exportState) ensureCardSelectionVisible(width, height int) {842+func (s *exportModel) ensureCardSelectionVisible(width, height int) {788843 cols := s.cardColumns(width)789844 if cols <= 0 {790845 return@@ -801,7 +856,7 @@ func (s *exportState) ensureCardSelectionVisible(width, height int) {
801856 s.cardRowOffset = min(max(s.cardRowOffset, 0), maxRows-1)802857 }803858 804-func (s *exportState) visibleCardRows(width, height int) int {859+func (s *exportModel) visibleCardRows(width, height int) int {805860 if len(s.cards) == 0 {806861 return 1807862 }@@ -838,14 +893,14 @@ func canFitCardRows(available, cardHeight, rows int) bool {
838893 return available >= required839894 }840895 841-func (s *exportState) view(width, height int) string {896+func (s *exportModel) view(width, height int) string {842897 settings := s.renderSettings(width)843898 summary := s.renderSummary(width)844899 cards := s.renderCards(width, height)845900 return lipgloss.JoinVertical(lipgloss.Left, settings, summary, cards)846901 }847902 848-func (s *exportState) contentMetrics(width, height int) exportContentMetrics {903+func (s *exportModel) contentMetrics(width, height int) exportContentMetrics {849904 settings := s.settingsRects(width)850905 settingsHeight := settings.TotalHeight851906 summaryHeight := lipgloss.Height(s.renderSummary(width))@@ -887,7 +942,7 @@ func (s *exportState) contentMetrics(width, height int) exportContentMetrics {
887942 }888943 }889944 890-func (s *exportState) settingsRects(width int) exportSettingsRects {945+func (s *exportModel) settingsRects(width int) exportSettingsRects {891946 if width < 72 {892947 return s.stackedSettingsRects(width)893948 }@@ -929,7 +984,7 @@ func (s *exportState) settingsRects(width int) exportSettingsRects {
929984 return row1930985 }931986 932-func (s *exportState) stackedSettingsRects(width int) exportSettingsRects {987+func (s *exportModel) stackedSettingsRects(width int) exportSettingsRects {933988 y := 0934989 inlineHeight := s.inlineFieldHeight(width)935990 headerHeight := s.headerFieldHeight(width)@@ -972,17 +1027,17 @@ func (s *exportState) stackedSettingsRects(width int) exportSettingsRects {
9721027 return rects9731028 }9741029 975-func (s *exportState) inlineFieldHeight(width int) int {1030+func (s *exportModel) inlineFieldHeight(width int) int {9761031 input := s.titleInput9771032 return lipgloss.Height(s.renderInlineInputField("Title", &input, false, max(width, 14)))9781033 }9791034 980-func (s *exportState) headerFieldHeight(width int) int {1035+func (s *exportModel) headerFieldHeight(width int) int {9811036 input := s.headerInput9821037 return lipgloss.Height(s.renderTextareaField("Header", &input, false, max(width, 24)))9831038 }9841039 985-func (s *exportState) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {1040+func (s *exportModel) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {9861041 if len(s.cards) == 0 || cardHeight <= 0 {9871042 return nil9881043 }@@ -1009,7 +1064,7 @@ func (s *exportState) cardRects(width, cardsY, cardWidth, cardHeight, visibleRow
10091064 return layouts10101065 }10111066 1012-func (s *exportState) bucketRectsForCard(index int, rect exportRect) [3]exportRect {1067+func (s *exportModel) bucketRectsForCard(index int, rect exportRect) [3]exportRect {10131068 var rects [3]exportRect10141069 card := s.cards[index]10151070 selected := index == s.cardIndex@@ -1024,7 +1079,7 @@ func (s *exportState) bucketRectsForCard(index int, rect exportRect) [3]exportRe
10241079 return rects10251080 }10261081 1027-func (s *exportState) renderSettings(width int) string {1082+func (s *exportModel) renderSettings(width int) string {10281083 if width < 72 {10291084 return s.renderStackedSettings(width)10301085 }@@ -1065,7 +1120,7 @@ func (s *exportState) renderSettings(width int) string {
10651120 return lipgloss.JoinVertical(lipgloss.Left, rows...)10661121 }10671122 1068-func (s *exportState) renderStackedSettings(width int) string {1123+func (s *exportModel) renderStackedSettings(width int) string {10691124 rows := []string{10701125 s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, width),10711126 s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, width),@@ -1100,7 +1155,7 @@ func (s *exportState) renderStackedSettings(width int) string {
11001155 return lipgloss.JoinVertical(lipgloss.Left, rows...)11011156 }11021157 1103-func (s *exportState) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {1158+func (s *exportModel) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {11041159 border := theme.Current().Border11051160 if focused {11061161 border = theme.Current().Accent@@ -1123,7 +1178,7 @@ func (s *exportState) renderTextareaField(label string, input *textarea.Model, f
11231178 Render(body)11241179 }11251180 1126-func (s *exportState) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {1181+func (s *exportModel) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {11271182 border := theme.Current().Border11281183 if focused {11291184 border = theme.Current().Accent@@ -1148,7 +1203,7 @@ func (s *exportState) renderInlineInputField(label string, input *textinput.Mode
11481203 Render(body)11491204 }11501205 1151-func (s *exportState) renderInlineChoiceField(label, value string, focused bool, width int) string {1206+func (s *exportModel) renderInlineChoiceField(label, value string, focused bool, width int) string {11521207 border := theme.Current().Border11531208 if focused {11541209 border = theme.Current().Accent@@ -1217,7 +1272,7 @@ func onOffLabel(enabled bool) string {
12171272 return "Off"12181273 }12191274 1220-func (s *exportState) renderSummary(width int) string {1275+func (s *exportModel) renderSummary(width int) string {12211276 total := 012221277 for _, card := range s.cards {12231278 for _, count := range card.Buckets {@@ -1245,7 +1300,7 @@ func (s *exportState) renderSummary(width int) string {
12451300 12461301 var bucketNames = [3]string{"Easy", "Medium", "Hard"}12471302 1248-func (s *exportState) renderCards(width, height int) string {1303+func (s *exportModel) renderCards(width, height int) string {12491304 if len(s.cards) == 0 {12501305 return ui.DimItemStyle().Render("No exportable games found.")12511306 }@@ -1278,7 +1333,7 @@ func (s *exportState) renderCards(width, height int) string {
12781333 return content12791334 }12801335 1281-func (s *exportState) renderCard(index, width int) string {1336+func (s *exportModel) renderCard(index, width int) string {12821337 card := s.cards[index]12831338 selected := index == s.cardIndex12841339 @@ -1321,7 +1376,7 @@ func (s *exportState) renderCard(index, width int) string {
13211376 Render(body)13221377 }13231378 1324-func (s *exportState) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {1379+func (s *exportModel) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {13251380 palette := theme.Current()13261381 bg, fg := exportDifficultyColors(bucketIndex, palette)13271382 pairStyle := lipgloss.NewStyle().@@ -1340,28 +1395,28 @@ func (s *exportState) renderDifficultyPair(card exportGameCard, cardSelected boo
13401395 return pair13411396 }13421397 1343-func (s *exportState) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, screenAction, bool) {1398+func (s *exportModel) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, bool) {13441399 mouse := msg.Mouse()13451400 content := s.view(contentWidth, contentHeight)13461401 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)13471402 localX := mouse.X - contentX13481403 localY := mouse.Y - contentY13491404 if localX < 0 || localY < 0 {1350- return nil, nil, false1405+ return nil, false13511406 }13521407 13531408 metrics := s.contentMetrics(contentWidth, contentHeight)13541409 if cmd, handled := s.handleSettingsClick(localX, localY, metrics); handled {1355- return cmd, nil, true1410+ return cmd, true13561411 }13571412 if metrics.SummaryButton.contains(localX, localY) {13581413 s.focus = exportFocusSubmit1359- return s.applyFocus(), exportSubmitAction{}, true1414+ return tea.Batch(s.applyFocus(), exportSubmitCmd), true13601415 }13611416 if cmd, handled := s.handleCardClick(localX, localY, metrics); handled {1362- return cmd, nil, true1417+ return cmd, true13631418 }1364- return nil, nil, false1419+ return nil, false13651420 }13661421 13671422 func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (panelX, panelY, contentX, contentY int) {@@ -1377,7 +1432,7 @@ func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (pa
13771432 return panelX, panelY, contentX, contentY13781433 }13791434 1380-func (s *exportState) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {1435+func (s *exportModel) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {13811436 mouse := msg.Mouse()13821437 content := s.view(contentWidth, contentHeight)13831438 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)@@ -1406,7 +1461,7 @@ func (s *exportState) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, scree
14061461 return true14071462 }14081463 1409-func (s *exportState) clampSelectionToVisibleRows(cols, visibleRows int) {1464+func (s *exportModel) clampSelectionToVisibleRows(cols, visibleRows int) {14101465 if len(s.cards) == 0 || cols <= 0 || visibleRows <= 0 {14111466 return14121467 }@@ -1426,7 +1481,7 @@ func (s *exportState) clampSelectionToVisibleRows(cols, visibleRows int) {
14261481 s.cardIndex = max(index, 0)14271482 }14281483 1429-func (s *exportState) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1484+func (s *exportModel) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {14301485 settings := metrics.Settings14311486 switch {14321487 case settings.Title.contains(localX, localY):@@ -1456,7 +1511,7 @@ func (s *exportState) handleSettingsClick(localX, localY int, metrics exportCont
14561511 }14571512 }14581513 1459-func (s *exportState) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {1514+func (s *exportModel) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {14601515 s.focus = focus14611516 labelWidth := lipgloss.Width(label) + 114621517 cursorPos := max(localX-rect.X-2-labelWidth, 0)@@ -1464,7 +1519,7 @@ func (s *exportState) focusTextInput(focus exportFocus, input *textinput.Model,
14641519 return s.applyFocus()14651520 }14661521 1467-func (s *exportState) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {1522+func (s *exportModel) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {14681523 s.focus = exportFocusHeader14691524 line := max(localY-rect.Y-2, 0)14701525 col := max(localX-rect.X-2, 0)@@ -1478,7 +1533,7 @@ func (s *exportState) focusTextarea(rect exportRect, localX, localY int) tea.Cmd
14781533 return s.applyFocus()14791534 }14801535 1481-func (s *exportState) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1536+func (s *exportModel) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {14821537 if len(s.cards) == 0 {14831538 return nil, false14841539 }@@ -1496,7 +1551,7 @@ func (s *exportState) handleCardClick(localX, localY int, metrics exportContentM
14961551 return nil, false14971552 }14981553 1499-func (s *exportState) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {1554+func (s *exportModel) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {15001555 s.focus = exportFocusCards15011556 s.cardIndex = cardIndex15021557 s.bucketIndex = min(max(bucketIndex, 0), len(bucketNames)-1)@@ -1569,7 +1624,7 @@ func truncateLabel(s string, width int) string {
15691624 return string(runes[:width-1]) + "…"15701625 }15711626 1572-func (s exportState) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {1627+func (s exportModel) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {15731628 sync := s15741629 sync.syncValuesFromInputs()15751630 @@ -1594,7 +1649,7 @@ func (s exportState) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {
15941649 return spec, cfg15951650 }15961651 1597-func (s *exportState) syncValuesFromInputs() {1652+func (s *exportModel) syncValuesFromInputs() {15981653 s.values.Title = s.titleInput.Value()15991654 s.values.Header = s.headerInput.Value()16001655 s.values.Advert = s.advertInput.Value()@@ -1650,3 +1705,16 @@ func expandCardCounts(cards []exportGameCard) map[puzzle.GameID]map[puzzle.ModeI
16501705 }16511706 return counts16521707 }1708+1709+1710+1711+1712+func runeIdxBeforeExt(path string) int {1713+ ext := filepath.Ext(path)1714+ runes := []rune(path)1715+ extRunes := len([]rune(ext))1716+ if extRunes == 0 || extRunes >= len(runes) {1717+ return len(runes)1718+ }1719+ return len(runes) - extRunes1720+}