- commit
- 1a915576c482edc8af57a81298887be8f463d2cc
- parent
- ddec2b5b57ce1f855b77c8f6e96005bbbf44d447
- author
- Dami <dami.etoile@gmail.com>
| A | app/ | 1652 | ++++++++++++++++++++++++++++++ |
| A | app/ | 539 | ++++++++++ |
| M | app/ | 3 | +- |
| M | app/ | 59 | +- |
| M | app/ | 17 | +- |
| M | app/ | 20 | + |
| M | app/ | 2 | + |
| M | app/ | 56 | +- |
| M | app/ | 18 | + |
| M | app/ | 2 | +- |
| M | cmd/ | 2 | + |
| M | cmd/ | 2 | +- |
| A | cmd/ | 48 | + |
| M | cmd/ | 116 | +-- |
| A | cmd/ | 61 | + |
| M | cmd/ | 6 | +- |
| M | cmd/ | 31 | +- |
| M | cmd/ | 17 | +- |
| M | cmd/ | 3 | +- |
| M | config/ | 21 | +- |
| M | config/ | 41 | + |
| M | go.mod | 9 | + |
| M | go.sum | 19 | + |
| M | justfile | 2 | +- |
| A | packexport/ | 176 | +++ |
| A | packexport/ | 123 | ++ |
| A | packexport/ | 195 | ++++ |
| A | packexport/ | 441 | ++++++++ |
| M | pdfexport/ | 35 | +- |
| M | ui/ | 9 | + |
@@ -0,0 +1,1652 @@
1+package app2+3+import (4+ "context"5+ "fmt"6+ "image/color"7+ "os"8+ "path/filepath"9+ "strconv"10+ "strings"11+12+ "github.com/FelineStateMachine/puzzletea/config"13+ puzzlegame "github.com/FelineStateMachine/puzzletea/game"14+ "github.com/FelineStateMachine/puzzletea/packexport"15+ "github.com/FelineStateMachine/puzzletea/puzzle"16+ "github.com/FelineStateMachine/puzzletea/theme"17+ "github.com/FelineStateMachine/puzzletea/ui"18+19+ "charm.land/bubbles/v2/key"20+ "charm.land/bubbles/v2/spinner"21+ "charm.land/bubbles/v2/textarea"22+ "charm.land/bubbles/v2/textinput"23+ tea "charm.land/bubbletea/v2"24+ "charm.land/lipgloss/v2"25+)26+27+type exportFocus int28+29+const (30+ exportFocusTitle exportFocus = iota31+ exportFocusHeader32+ exportFocusAdvert33+ exportFocusVolume34+ exportFocusLayout35+ exportFocusSeed36+ exportFocusPDFPath37+ exportFocusJSONLToggle38+ exportFocusJSONLPath39+ exportFocusCards40+ exportFocusSubmit41+)42+43+var exportSheetLayouts = []string{"half-letter", "duplex-booklet"}44+45+const exportFooterHint = "tab focus • arrows move buckets • digits set counts • +/- tweak • enter next/export • esc back"46+47+const (48+ exportPanelWidthPadding = 1049+ exportPanelHeightPadding = 1050+ exportCardGap = 151+ exportCardMinWidth = 1552+ exportCardMaxColumns = 553+ exportCardTargetRows = 354+)55+56+type exportFormValues struct {57+ Title string58+ Header string59+ Advert string60+ Volume string61+ SheetLayout string62+ Seed string63+ PDFOutputPath string64+ JSONLEnabled bool65+ JSONLOutputPath string66+}67+68+type exportGameCard struct {69+ GameID puzzle.GameID70+ GameType string71+ Buckets [3]int72+ BucketModes [3][]packexport.ModeCatalog73+}74+75+type exportRect struct {76+ X int77+ Y int78+ W int79+ H int80+}81+82+func (r exportRect) contains(x, y int) bool {83+ return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H84+}85+86+type exportSettingsRects struct {87+ Title exportRect88+ Advert exportRect89+ Header exportRect90+ Volume exportRect91+ Layout exportRect92+ Seed exportRect93+ JSONL exportRect94+ PDFPath exportRect95+ JSONLPath exportRect96+ HasJSONL bool97+ TotalHeight int98+}99+100+type exportCardRects struct {101+ Index int102+ Rect exportRect103+ Buckets [3]exportRect104+}105+106+type exportCompleteMsg struct {107+ jobID int64108+ result packexport.Result109+ err error110+}111+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+}128+129+func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {130+ if clickMsg, ok := msg.(tea.MouseClickMsg); ok {131+ 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, action134+ }135+ return s, nil, nil136+ }137+ if wheelMsg, ok := msg.(tea.MouseWheelMsg); ok {138+ 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, nil141+ }142+ }143+144+ keyMsg, ok := msg.(tea.KeyPressMsg)145+ if ok {146+ switch {147+ case key.Matches(keyMsg, rootKeys.Escape):148+ return s, nil, backAction{target: mainMenuView}149+ case keyMsg.String() == "tab":150+ cmd := s.export.moveFocus(1)151+ return s, cmd, nil152+ case keyMsg.String() == "shift+tab":153+ cmd := s.export.moveFocus(-1)154+ return s, cmd, nil155+ case key.Matches(keyMsg, rootKeys.Enter):156+ if s.export.focus == exportFocusSubmit {157+ return s, nil, exportSubmitAction{}158+ }159+ if s.export.focus == exportFocusHeader {160+ break161+ }162+ if s.export.focus == exportFocusLayout {163+ s.export.cycleLayout(1)164+ return s, nil, nil165+ }166+ if s.export.focus == exportFocusJSONLToggle {167+ s.export.toggleJSONL()168+ return s, nil, nil169+ }170+ cmd := s.export.moveFocus(1)171+ return s, cmd, nil172+ }173+174+ contentWidth, contentHeight := exportContentBounds(s.width, s.height)175+ if handled := s.export.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {176+ return s, nil, nil177+ }178+ }179+180+ cmd := s.export.updateFocusedInput(msg)181+ return s, cmd, nil182+}183+184+func (s exportScreen) View(notice noticeState) string {185+ contentWidth, contentHeight := exportContentBounds(s.width, s.height)186+ return renderPanelView(187+ s.width,188+ s.height,189+ notice,190+ "Export",191+ s.export.view(contentWidth, contentHeight),192+ exportFooterHint,193+ )194+}195+196+func (s exportScreen) Apply(m model) model {197+ m.state = exportView198+ m.export = s.export199+ return m200+}201+202+type exportRunningScreen struct {203+ width int204+ height int205+ spinner spinner.Model206+}207+208+func (s exportRunningScreen) State() viewState { return exportRunningView }209+210+func (s exportRunningScreen) Resize(width, height int) screenModel {211+ s.width = width212+ s.height = height213+ return s214+}215+216+func (s exportRunningScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {217+ var cmd tea.Cmd218+ s.spinner, cmd = s.spinner.Update(msg)219+ return s, cmd, nil220+}221+222+func (s exportRunningScreen) View(notice noticeState) string {223+ content := s.spinner.View() + " Exporting puzzle pack..."224+ box := ui.GeneratingFrame().Render(appendNoticeContent(s.width, notice, content))225+ return ui.CenterView(s.width, s.height, box)226+}227+228+func (s exportRunningScreen) Apply(m model) model {229+ m.state = exportRunningView230+ m.spinner = s.spinner231+ return m232+}233+234+func (m model) handleExportEnter() (tea.Model, tea.Cmd) {235+ if !m.export.initialized {236+ spec := m.initialExportSpec()237+ m.export = buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), m.width)238+ } else {239+ m.export.resize(m.width)240+ }241+ m.state = exportView242+ m = m.clearNotice()243+ return m.resizeActiveScreen(), nil244+}245+246+func (m model) handleExportSubmit() (tea.Model, tea.Cmd) {247+ spec, cfg := m.export.toSpecAndConfig()248+ if err := packexport.ValidateSpec(spec); err != nil {249+ return m.setErrorf("Could not start export: %v", err), nil250+ }251+252+ m.cfg.Export = cfg253+ if err := m.cfg.Save(m.configPath); err != nil {254+ m = m.setErrorf("Export settings save failed: %v", err)255+ }256+257+ cmd := m.startExport(spec)258+ return m, cmd259+}260+261+func (m model) handleExportComplete(msg exportCompleteMsg) (tea.Model, tea.Cmd) {262+ if msg.jobID != m.export.jobID {263+ return m, nil264+ }265+266+ m.export.running = false267+ if m.export.cancel != nil {268+ m.export.cancel()269+ m.export.cancel = nil270+ }271+272+ m.state = exportView273+274+ if msg.err != nil {275+ return m.setErrorf("Export failed: %v", msg.err), nil276+ }277+278+ message := fmt.Sprintf("Exported %d puzzles to %s", msg.result.TotalCount, msg.result.PDFOutputPath)279+ if msg.result.JSONLOutputPath != "" {280+ message += fmt.Sprintf(" and %s", msg.result.JSONLOutputPath)281+ }282+ return m.setSuccessf("%s", message), nil283+}284+285+func (m *model) cancelActiveExport() {286+ if m.export.cancel != nil {287+ m.export.cancel()288+ m.export.cancel = nil289+ }290+ if m.export.running {291+ m.export.jobID++292+ }293+ m.export.running = false294+}295+296+func (m *model) startExport(spec packexport.Spec) tea.Cmd {297+ m.cancelActiveExport()298+ m.export.jobID++299+ jobID := m.export.jobID300+ ctx, cancel := context.WithCancel(context.Background())301+ m.export.cancel = cancel302+ m.export.running = true303+ m.state = exportRunningView304+ *m = m.clearNotice()305+ return tea.Batch(m.spinner.Tick, exportCmd(ctx, jobID, spec))306+}307+308+func exportCmd(ctx context.Context, jobID int64, spec packexport.Spec) tea.Cmd {309+ return func() tea.Msg {310+ result, err := packexport.Run(ctx, spec)311+ return exportCompleteMsg{jobID: jobID, result: result, err: err}312+ }313+}314+315+func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportState {316+ contentWidth, _ := exportContentBounds(width, 0)317+ state := exportState{318+ initialized: true,319+ focus: exportFocusCards,320+ values: values,321+ cards: cards,322+ }323+ state.initInputs(contentWidth)324+ return state325+}326+327+func exportContentBounds(width, height int) (int, int) {328+ return max(width-exportPanelWidthPadding, 24), max(height-exportPanelHeightPadding, 8)329+}330+331+func (m model) initialExportSpec() packexport.Spec {332+ cwd, err := os.Getwd()333+ if err != nil {334+ cwd = "."335+ }336+337+ base := packexport.DefaultSpec(cwd)338+ if !hasSavedExportConfig(m.cfg.Export) {339+ return base340+ }341+342+ return applySavedExportConfig(base, m.cfg.Export)343+}344+345+func hasSavedExportConfig(cfg config.ExportConfig) bool {346+ return cfg.Title != "" ||347+ cfg.Header != "" ||348+ cfg.Advert != "" ||349+ cfg.Volume != 0 ||350+ cfg.SheetLayout != "" ||351+ cfg.Seed != "" ||352+ cfg.PDFOutputPath != "" ||353+ cfg.JSONLOutputPath != "" ||354+ cfg.JSONLEnabled ||355+ len(cfg.Counts) > 0356+}357+358+func applySavedExportConfig(base packexport.Spec, cfg config.ExportConfig) packexport.Spec {359+ if cfg.Title != "" {360+ base.Title = cfg.Title361+ }362+ if cfg.Header != "" {363+ base.Header = cfg.Header364+ }365+ if cfg.Advert != "" {366+ base.Advert = cfg.Advert367+ }368+ if cfg.Volume > 0 {369+ base.Volume = cfg.Volume370+ }371+ if cfg.SheetLayout != "" {372+ base.SheetLayout = cfg.SheetLayout373+ }374+ base.Seed = cfg.Seed375+ if cfg.PDFOutputPath != "" {376+ base.PDFOutputPath = cfg.PDFOutputPath377+ }378+ if cfg.JSONLEnabled {379+ base.JSONLOutputPath = cfg.JSONLOutputPath380+ } else {381+ base.JSONLOutputPath = ""382+ }383+ for gameID, modes := range cfg.Counts {384+ if _, ok := base.Counts[gameID]; !ok {385+ base.Counts[gameID] = map[puzzle.ModeID]int{}386+ }387+ for modeID, count := range modes {388+ base.Counts[gameID][modeID] = count389+ }390+ }391+ return base392+}393+394+func exportValuesFromSpec(spec packexport.Spec) exportFormValues {395+ values := exportFormValues{396+ Title: spec.Title,397+ Header: spec.Header,398+ Advert: spec.Advert,399+ Volume: strconv.Itoa(spec.Volume),400+ SheetLayout: spec.SheetLayout,401+ Seed: spec.Seed,402+ PDFOutputPath: spec.PDFOutputPath,403+ JSONLEnabled: strings.TrimSpace(spec.JSONLOutputPath) != "",404+ JSONLOutputPath: defaultJSONLPath(spec),405+ }406+ if strings.TrimSpace(spec.JSONLOutputPath) != "" {407+ values.JSONLOutputPath = spec.JSONLOutputPath408+ }409+ return values410+}411+412+func defaultJSONLPath(spec packexport.Spec) string {413+ dir := filepath.Dir(spec.PDFOutputPath)414+ if dir == "." || dir == "" {415+ dir = "out"416+ }417+ return filepath.Join(dir, "puzzletea-export.jsonl")418+}419+420+func buildExportCardsFromSpec(spec packexport.Spec) []exportGameCard {421+ catalog := packexport.ExportCatalog()422+ cards := make([]exportGameCard, 0, len(catalog))423+ for _, game := range catalog {424+ card := exportGameCard{425+ GameID: game.GameID,426+ GameType: game.GameType,427+ }428+ for i, mode := range game.Modes {429+ bucket := bucketIndexForMode(i, len(game.Modes))430+ card.BucketModes[bucket] = append(card.BucketModes[bucket], mode)431+ if modes, ok := spec.Counts[mode.GameID]; ok {432+ card.Buckets[bucket] += modes[mode.ModeID]433+ }434+ }435+ cards = append(cards, card)436+ }437+ return cards438+}439+440+func bucketIndexForMode(index, total int) int {441+ if total <= 0 {442+ return 0443+ }444+ return index * 3 / total445+}446+447+func (s *exportState) initInputs(width int) {448+ s.titleInput = newExportInput(s.values.Title, "Sampler title")449+ s.headerInput = newExportTextarea(s.values.Header, "Short intro")450+ s.advertInput = newExportInput(s.values.Advert, "Footer copy")451+ s.volumeInput = newExportInput(s.values.Volume, "1")452+ s.seedInput = newExportInput(s.values.Seed, "optional seed")453+ s.pdfPathInput = newExportInput(s.values.PDFOutputPath, "output pdf")454+ s.jsonlPathInput = newExportInput(s.values.JSONLOutputPath, "optional jsonl")455+ s.resize(width)456+ s.applyFocus()457+}458+459+func newExportInput(value, placeholder string) textinput.Model {460+ input := textinput.New()461+ input.Prompt = ""462+ input.SetValue(value)463+ input.Placeholder = placeholder464+ input.CharLimit = 512465+ styleExportInput(&input)466+ return input467+}468+469+func newExportTextarea(value, placeholder string) textarea.Model {470+ input := textarea.New()471+ input.Prompt = ""472+ input.SetValue(value)473+ input.Placeholder = placeholder474+ input.CharLimit = 512475+ input.ShowLineNumbers = false476+ input.EndOfBufferCharacter = ' '477+ input.SetHeight(3)478+ styleExportTextarea(&input)479+ return input480+}481+482+func styleExportInput(input *textinput.Model) {483+ palette := theme.Current()484+ styles := input.Styles()485+ styles.Focused.Text = lipgloss.NewStyle().Foreground(palette.FG)486+ styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)487+ styles.Focused.Prompt = lipgloss.NewStyle().Foreground(palette.Accent)488+ styles.Blurred.Text = lipgloss.NewStyle().Foreground(palette.FG)489+ styles.Blurred.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)490+ styles.Blurred.Prompt = lipgloss.NewStyle().Foreground(palette.TextDim)491+ styles.Cursor.Color = palette.Accent492+ input.SetStyles(styles)493+}494+495+func styleExportTextarea(input *textarea.Model) {496+ palette := theme.Current()497+ styles := input.Styles()498+ styles.Focused.Base = lipgloss.NewStyle()499+ styles.Focused.Text = lipgloss.NewStyle().Foreground(palette.FG)500+ styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)501+ styles.Focused.Prompt = lipgloss.NewStyle().Foreground(palette.Accent)502+ styles.Focused.CursorLine = lipgloss.NewStyle()503+ styles.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(palette.TextDim)504+ styles.Blurred.Base = lipgloss.NewStyle()505+ styles.Blurred.Text = lipgloss.NewStyle().Foreground(palette.FG)506+ styles.Blurred.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)507+ styles.Blurred.Prompt = lipgloss.NewStyle().Foreground(palette.TextDim)508+ styles.Blurred.CursorLine = lipgloss.NewStyle()509+ styles.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(palette.TextDim)510+ styles.Cursor.Color = palette.Accent511+ input.SetStyles(styles)512+}513+514+func (s *exportState) resize(width int) {515+ styleExportInput(&s.titleInput)516+ styleExportTextarea(&s.headerInput)517+ styleExportInput(&s.advertInput)518+ styleExportInput(&s.volumeInput)519+ styleExportInput(&s.seedInput)520+ styleExportInput(&s.pdfPathInput)521+ styleExportInput(&s.jsonlPathInput)522+523+ fieldW := max(18, min((width-22)/3, 38))524+ s.titleInput.SetWidth(fieldW)525+ s.headerInput.SetWidth(fieldW)526+ s.headerInput.SetHeight(3)527+ s.advertInput.SetWidth(fieldW)528+ s.volumeInput.SetWidth(6)529+ s.seedInput.SetWidth(max(18, min(fieldW, 26)))530+ s.pdfPathInput.SetWidth(max(26, min(width-20, 72)))531+ s.jsonlPathInput.SetWidth(max(26, min(width-20, 72)))532+}533+534+func (s *exportState) visibleFocuses() []exportFocus {535+ focuses := []exportFocus{536+ exportFocusTitle,537+ exportFocusAdvert,538+ exportFocusHeader,539+ exportFocusVolume,540+ exportFocusLayout,541+ exportFocusSeed,542+ exportFocusPDFPath,543+ exportFocusJSONLToggle,544+ }545+ if s.values.JSONLEnabled {546+ focuses = append(focuses, exportFocusJSONLPath)547+ }548+ focuses = append(focuses, exportFocusCards, exportFocusSubmit)549+ return focuses550+}551+552+func (s *exportState) moveFocus(delta int) tea.Cmd {553+ focuses := s.visibleFocuses()554+ current := 0555+ for i, focus := range focuses {556+ if focus == s.focus {557+ current = i558+ break559+ }560+ }561+ next := (current + delta) % len(focuses)562+ if next < 0 {563+ next += len(focuses)564+ }565+ s.focus = focuses[next]566+ return s.applyFocus()567+}568+569+func (s *exportState) applyFocus() tea.Cmd {570+ s.titleInput.Blur()571+ s.headerInput.Blur()572+ s.advertInput.Blur()573+ s.volumeInput.Blur()574+ s.seedInput.Blur()575+ s.pdfPathInput.Blur()576+ s.jsonlPathInput.Blur()577+578+ switch s.focus {579+ case exportFocusTitle:580+ return s.titleInput.Focus()581+ case exportFocusHeader:582+ return s.headerInput.Focus()583+ case exportFocusAdvert:584+ return s.advertInput.Focus()585+ case exportFocusVolume:586+ return s.volumeInput.Focus()587+ case exportFocusSeed:588+ return s.seedInput.Focus()589+ case exportFocusPDFPath:590+ return s.pdfPathInput.Focus()591+ case exportFocusJSONLPath:592+ return s.jsonlPathInput.Focus()593+ default:594+ return nil595+ }596+}597+598+func (s *exportState) updateFocusedInput(msg tea.Msg) tea.Cmd {599+ var cmd tea.Cmd600+ switch s.focus {601+ case exportFocusTitle:602+ s.titleInput, cmd = s.titleInput.Update(msg)603+ s.values.Title = s.titleInput.Value()604+ case exportFocusHeader:605+ s.headerInput, cmd = s.headerInput.Update(msg)606+ s.values.Header = s.headerInput.Value()607+ case exportFocusAdvert:608+ s.advertInput, cmd = s.advertInput.Update(msg)609+ s.values.Advert = s.advertInput.Value()610+ case exportFocusVolume:611+ s.volumeInput, cmd = s.volumeInput.Update(msg)612+ s.values.Volume = s.volumeInput.Value()613+ case exportFocusSeed:614+ s.seedInput, cmd = s.seedInput.Update(msg)615+ s.values.Seed = s.seedInput.Value()616+ case exportFocusPDFPath:617+ s.pdfPathInput, cmd = s.pdfPathInput.Update(msg)618+ s.values.PDFOutputPath = s.pdfPathInput.Value()619+ case exportFocusJSONLPath:620+ s.jsonlPathInput, cmd = s.jsonlPathInput.Update(msg)621+ s.values.JSONLOutputPath = s.jsonlPathInput.Value()622+ }623+ return cmd624+}625+626+func (s *exportState) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {627+ switch s.focus {628+ case exportFocusLayout:629+ switch msg.String() {630+ case "left", "h":631+ s.cycleLayout(-1)632+ return true633+ case "right", "l":634+ s.cycleLayout(1)635+ return true636+ }637+ case exportFocusJSONLToggle:638+ switch msg.String() {639+ case "left", "right", "h", "l", " ":640+ s.toggleJSONL()641+ return true642+ }643+ case exportFocusCards:644+ switch msg.String() {645+ case "left":646+ s.moveBucketSelection(-1, width, height)647+ return true648+ case "right":649+ s.moveBucketSelection(1, width, height)650+ return true651+ case "h":652+ s.moveBucketSelection(-1, width, height)653+ return true654+ case "l":655+ s.moveBucketSelection(1, width, height)656+ return true657+ case "up", "k":658+ cols := s.cardColumns(width)659+ if s.cardIndex-cols >= 0 {660+ s.cardIndex -= cols661+ }662+ s.ensureCardSelectionVisible(width, height)663+ return true664+ case "down", "j":665+ cols := s.cardColumns(width)666+ if s.cardIndex+cols < len(s.cards) {667+ s.cardIndex += cols668+ }669+ s.ensureCardSelectionVisible(width, height)670+ return true671+ case "+", "=":672+ s.adjustSelectedBucket(1)673+ return true674+ case "-":675+ s.adjustSelectedBucket(-1)676+ return true677+ case "backspace", "delete":678+ s.backspaceSelectedBucket()679+ return true680+ case "pgup":681+ s.cardRowOffset = max(s.cardRowOffset-1, 0)682+ return true683+ case "pgdown":684+ s.cardRowOffset++685+ s.ensureCardSelectionVisible(width, height)686+ return true687+ case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":688+ s.appendSelectedBucketDigit(msg.String())689+ return true690+ }691+ }692+ return false693+}694+695+func (s *exportState) moveBucketSelection(delta, width, height int) {696+ if len(s.cards) == 0 || delta == 0 {697+ return698+ }699+ position := s.cardIndex*len(bucketNames) + s.bucketIndex + delta700+ maxPosition := len(s.cards)*len(bucketNames) - 1701+ position = max(0, min(position, maxPosition))702+ s.cardIndex = position / len(bucketNames)703+ s.bucketIndex = position % len(bucketNames)704+ s.ensureCardSelectionVisible(width, height)705+}706+707+func (s *exportState) appendSelectedBucketDigit(raw string) {708+ if len(s.cards) == 0 {709+ return710+ }711+ digit, err := strconv.Atoi(raw)712+ if err != nil {713+ return714+ }715+ card := &s.cards[s.cardIndex]716+ if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {717+ return718+ }719+ card.Buckets[s.bucketIndex] = card.Buckets[s.bucketIndex]*10 + digit720+}721+722+func (s *exportState) backspaceSelectedBucket() {723+ if len(s.cards) == 0 {724+ return725+ }726+ card := &s.cards[s.cardIndex]727+ card.Buckets[s.bucketIndex] /= 10728+}729+730+func (s *exportState) cycleLayout(delta int) {731+ index := 0732+ for i, layout := range exportSheetLayouts {733+ if layout == s.values.SheetLayout {734+ index = i735+ break736+ }737+ }738+ index = (index + delta) % len(exportSheetLayouts)739+ if index < 0 {740+ index += len(exportSheetLayouts)741+ }742+ s.values.SheetLayout = exportSheetLayouts[index]743+}744+745+func (s *exportState) toggleJSONL() {746+ s.values.JSONLEnabled = !s.values.JSONLEnabled747+ if !s.values.JSONLEnabled && s.focus == exportFocusJSONLPath {748+ s.focus = exportFocusCards749+ }750+}751+752+func (s *exportState) adjustSelectedBucket(delta int) {753+ if len(s.cards) == 0 {754+ return755+ }756+ card := &s.cards[s.cardIndex]757+ if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {758+ return759+ }760+ card.Buckets[s.bucketIndex] = max(card.Buckets[s.bucketIndex]+delta, 0)761+}762+763+func (c exportGameCard) bucketSeeded(index int) bool {764+ if index < 0 || index >= len(c.BucketModes) {765+ return true766+ }767+ if len(c.BucketModes[index]) == 0 {768+ return true769+ }770+ for _, mode := range c.BucketModes[index] {771+ if !mode.Seeded {772+ return false773+ }774+ }775+ return true776+}777+778+func (s *exportState) cardColumns(width int) int {779+ for cols := exportCardMaxColumns; cols >= 1; cols-- {780+ if exportCardWidth(width, cols) >= exportCardMinWidth {781+ return cols782+ }783+ }784+ return 1785+}786+787+func (s *exportState) ensureCardSelectionVisible(width, height int) {788+ cols := s.cardColumns(width)789+ if cols <= 0 {790+ return791+ }792+ selectedRow := s.cardIndex / cols793+ visibleRows := s.visibleCardRows(width, height)794+ if selectedRow < s.cardRowOffset {795+ s.cardRowOffset = selectedRow796+ }797+ if selectedRow >= s.cardRowOffset+visibleRows {798+ s.cardRowOffset = selectedRow - visibleRows + 1799+ }800+ maxRows := max((len(s.cards)+cols-1)/cols, 1)801+ s.cardRowOffset = min(max(s.cardRowOffset, 0), maxRows-1)802+}803+804+func (s *exportState) visibleCardRows(width, height int) int {805+ if len(s.cards) == 0 {806+ return 1807+ }808+809+ cardWidth := exportCardWidth(width, s.cardColumns(width))810+ cardHeight := lipgloss.Height(s.renderCard(0, cardWidth))811+ if cardHeight <= 0 {812+ cardHeight = 4813+ }814+815+ fixedHeight := lipgloss.Height(s.renderSettings(width)) + lipgloss.Height(s.renderSummary(width))816+ available := max(height-fixedHeight-1, cardHeight)817+818+ rows := 1819+ used := cardHeight820+ for used+1+cardHeight <= available {821+ rows++822+ used += 1 + cardHeight823+ }824+ if canFitCardRows(available, cardHeight, exportCardTargetRows) {825+ rows = max(rows, exportCardTargetRows)826+ }827+ return rows828+}829+830+func canFitCardRows(available, cardHeight, rows int) bool {831+ if available <= 0 || cardHeight <= 0 || rows <= 0 {832+ return false833+ }834+ required := rows * cardHeight835+ if rows > 1 {836+ required += rows - 1837+ }838+ return available >= required839+}840+841+func (s *exportState) view(width, height int) string {842+ settings := s.renderSettings(width)843+ summary := s.renderSummary(width)844+ cards := s.renderCards(width, height)845+ return lipgloss.JoinVertical(lipgloss.Left, settings, summary, cards)846+}847+848+func (s *exportState) contentMetrics(width, height int) exportContentMetrics {849+ settings := s.settingsRects(width)850+ settingsHeight := settings.TotalHeight851+ summaryHeight := lipgloss.Height(s.renderSummary(width))852+ cardWidth := exportCardWidth(width, s.cardColumns(width))853+ cardHeight := 0854+ if len(s.cards) > 0 {855+ cardHeight = lipgloss.Height(s.renderCard(0, cardWidth))856+ }857+ summaryY := settingsHeight858+ summaryButton := exportRect{859+ X: max(width-lipgloss.Width(renderChoiceChip("Export", s.focus == exportFocusSubmit)), 0),860+ Y: summaryY,861+ W: lipgloss.Width(renderChoiceChip("Export", s.focus == exportFocusSubmit)),862+ H: summaryHeight,863+ }864+ cardsY := settingsHeight + summaryHeight865+ visibleRows := s.visibleCardRows(width, height)866+ cardRects := s.cardRects(width, cardsY, cardWidth, cardHeight, visibleRows)867+ return exportContentMetrics{868+ Width: width,869+ Height: height,870+ SettingsHeight: settingsHeight,871+ SummaryHeight: summaryHeight,872+ Settings: settings,873+ Summary: exportRect{X: 0, Y: summaryY, W: width, H: summaryHeight},874+ SummaryButton: summaryButton,875+ CardsRect: exportRect{876+ X: 0,877+ Y: cardsY,878+ W: width,879+ H: max(visibleRows*cardHeight+max(visibleRows-1, 0), 0),880+ },881+ CardsY: cardsY,882+ CardWidth: cardWidth,883+ CardHeight: cardHeight,884+ CardColumns: s.cardColumns(width),885+ VisibleRows: visibleRows,886+ CardRects: cardRects,887+ }888+}889+890+func (s *exportState) settingsRects(width int) exportSettingsRects {891+ if width < 72 {892+ return s.stackedSettingsRects(width)893+ }894+895+ y := 0896+ inlineHeight := s.inlineFieldHeight(width)897+ headerHeight := s.headerFieldHeight(width)898+899+ titleW := max((width-2)/2, 24)900+ advertW := max(width-titleW-2, 24)901+ row1 := exportSettingsRects{902+ Title: exportRect{X: 0, Y: y, W: titleW, H: inlineHeight},903+ Advert: exportRect{X: titleW + 2, Y: y, W: advertW, H: inlineHeight},904+ }905+ y += inlineHeight906+ row1.Header = exportRect{X: 0, Y: y, W: width, H: headerHeight}907+ y += headerHeight908+909+ volumeW := 14910+ layoutW := 18911+ jsonlW := 14912+ seedW := max(width-volumeW-layoutW-jsonlW-6, 22)913+ row1.Volume = exportRect{X: 0, Y: y, W: volumeW, H: inlineHeight}914+ row1.Layout = exportRect{X: volumeW + 2, Y: y, W: layoutW, H: inlineHeight}915+ row1.Seed = exportRect{X: volumeW + layoutW + 4, Y: y, W: seedW, H: inlineHeight}916+ row1.JSONL = exportRect{X: volumeW + layoutW + seedW + 6, Y: y, W: jsonlW, H: inlineHeight}917+ y += inlineHeight918+919+ row1.PDFPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}920+ y += inlineHeight921+922+ if s.values.JSONLEnabled {923+ row1.HasJSONL = true924+ row1.JSONLPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}925+ y += inlineHeight926+ }927+928+ row1.TotalHeight = y929+ return row1930+}931+932+func (s *exportState) stackedSettingsRects(width int) exportSettingsRects {933+ y := 0934+ inlineHeight := s.inlineFieldHeight(width)935+ headerHeight := s.headerFieldHeight(width)936+ rects := exportSettingsRects{937+ Title: exportRect{X: 0, Y: y, W: width, H: inlineHeight},938+ }939+ y += inlineHeight940+ rects.Advert = exportRect{X: 0, Y: y, W: width, H: inlineHeight}941+ y += inlineHeight942+ rects.Header = exportRect{X: 0, Y: y, W: width, H: headerHeight}943+ y += headerHeight944+945+ volumeW, layoutW, jsonlW := 14, 18, 14946+ seedW := max(width-volumeW-layoutW-jsonlW-6, 16)947+ if seedW < 16 {948+ rects.Volume = exportRect{X: 0, Y: y, W: width, H: inlineHeight}949+ y += inlineHeight950+ rects.Layout = exportRect{X: 0, Y: y, W: width, H: inlineHeight}951+ y += inlineHeight952+ rects.Seed = exportRect{X: 0, Y: y, W: width, H: inlineHeight}953+ y += inlineHeight954+ rects.JSONL = exportRect{X: 0, Y: y, W: width, H: inlineHeight}955+ y += inlineHeight956+ } else {957+ rects.Volume = exportRect{X: 0, Y: y, W: volumeW, H: inlineHeight}958+ rects.Layout = exportRect{X: volumeW + 2, Y: y, W: layoutW, H: inlineHeight}959+ rects.Seed = exportRect{X: volumeW + layoutW + 4, Y: y, W: seedW, H: inlineHeight}960+ rects.JSONL = exportRect{X: volumeW + layoutW + seedW + 6, Y: y, W: jsonlW, H: inlineHeight}961+ y += inlineHeight962+ }963+964+ rects.PDFPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}965+ y += inlineHeight966+ if s.values.JSONLEnabled {967+ rects.HasJSONL = true968+ rects.JSONLPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}969+ y += inlineHeight970+ }971+ rects.TotalHeight = y972+ return rects973+}974+975+func (s *exportState) inlineFieldHeight(width int) int {976+ input := s.titleInput977+ return lipgloss.Height(s.renderInlineInputField("Title", &input, false, max(width, 14)))978+}979+980+func (s *exportState) headerFieldHeight(width int) int {981+ input := s.headerInput982+ return lipgloss.Height(s.renderTextareaField("Header", &input, false, max(width, 24)))983+}984+985+func (s *exportState) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {986+ if len(s.cards) == 0 || cardHeight <= 0 {987+ return nil988+ }989+ cols := s.cardColumns(width)990+ start := s.cardRowOffset * cols991+ end := min(len(s.cards), start+visibleRows*cols)992+ layouts := make([]exportCardRects, 0, end-start)993+ for i := start; i < end; i++ {994+ rel := i - start995+ row := rel / cols996+ col := rel % cols997+ rect := exportRect{998+ X: col * (cardWidth + exportCardGap),999+ Y: cardsY + row*(cardHeight+1),1000+ W: cardWidth,1001+ H: cardHeight,1002+ }1003+ layouts = append(layouts, exportCardRects{1004+ Index: i,1005+ Rect: rect,1006+ Buckets: s.bucketRectsForCard(i, rect),1007+ })1008+ }1009+ return layouts1010+}1011+1012+func (s *exportState) bucketRectsForCard(index int, rect exportRect) [3]exportRect {1013+ var rects [3]exportRect1014+ card := s.cards[index]1015+ selected := index == s.cardIndex1016+ x := rect.X + 21017+ y := rect.Y + 21018+ for bucketIndex, name := range bucketNames {1019+ part := s.renderDifficultyPair(card, selected, bucketIndex, name)1020+ w := lipgloss.Width(part)1021+ rects[bucketIndex] = exportRect{X: x, Y: y, W: w, H: 1}1022+ x += w + 11023+ }1024+ return rects1025+}1026+1027+func (s *exportState) renderSettings(width int) string {1028+ if width < 72 {1029+ return s.renderStackedSettings(width)1030+ }1031+1032+ titleW := max((width-2)/2, 24)1033+ advertW := max(width-titleW-2, 24)1034+ row1 := lipgloss.JoinHorizontal(lipgloss.Top,1035+ s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, titleW),1036+ " ",1037+ s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, advertW),1038+ )1039+ rowHeader := s.renderTextareaField("Header", &s.headerInput, s.focus == exportFocusHeader, width)1040+1041+ volumeW := 141042+ layoutW := 181043+ jsonlW := 141044+ seedW := max(width-volumeW-layoutW-jsonlW-6, 22)1045+ row2 := lipgloss.JoinHorizontal(lipgloss.Top,1046+ s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, volumeW),1047+ " ",1048+ s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, layoutW),1049+ " ",1050+ s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, seedW),1051+ " ",1052+ s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, jsonlW),1053+ )1054+1055+ rows := []string{1056+ row1,1057+ rowHeader,1058+ row2,1059+ s.renderInlineInputField("PDF Path", &s.pdfPathInput, s.focus == exportFocusPDFPath, width),1060+ }1061+ if s.values.JSONLEnabled {1062+ rows = append(rows, s.renderInlineInputField("JSONL Path", &s.jsonlPathInput, s.focus == exportFocusJSONLPath, width))1063+ }1064+1065+ return lipgloss.JoinVertical(lipgloss.Left, rows...)1066+}1067+1068+func (s *exportState) renderStackedSettings(width int) string {1069+ rows := []string{1070+ s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, width),1071+ s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, width),1072+ s.renderTextareaField("Header", &s.headerInput, s.focus == exportFocusHeader, width),1073+ }1074+1075+ volumeW, layoutW, jsonlW := 14, 18, 141076+ seedW := max(width-volumeW-layoutW-jsonlW-6, 16)1077+ if seedW < 16 {1078+ rows = append(rows,1079+ s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, width),1080+ s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, width),1081+ s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, width),1082+ s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, width),1083+ )1084+ } else {1085+ rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top,1086+ s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, volumeW),1087+ " ",1088+ s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, layoutW),1089+ " ",1090+ s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, seedW),1091+ " ",1092+ s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, jsonlW),1093+ ))1094+ }1095+1096+ rows = append(rows, s.renderInlineInputField("PDF Path", &s.pdfPathInput, s.focus == exportFocusPDFPath, width))1097+ if s.values.JSONLEnabled {1098+ rows = append(rows, s.renderInlineInputField("JSONL Path", &s.jsonlPathInput, s.focus == exportFocusJSONLPath, width))1099+ }1100+ return lipgloss.JoinVertical(lipgloss.Left, rows...)1101+}1102+1103+func (s *exportState) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {1104+ border := theme.Current().Border1105+ if focused {1106+ border = theme.Current().Accent1107+ }1108+1109+ innerWidth := max(width-4, 1)1110+ input.SetWidth(innerWidth)1111+ input.SetHeight(3)1112+1113+ body := lipgloss.JoinVertical(1114+ lipgloss.Left,1115+ ui.DimItemStyle().Render(label),1116+ input.View(),1117+ )1118+ return lipgloss.NewStyle().1119+ Border(lipgloss.RoundedBorder()).1120+ BorderForeground(border).1121+ Padding(0, 1).1122+ Width(width).1123+ Render(body)1124+}1125+1126+func (s *exportState) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {1127+ border := theme.Current().Border1128+ if focused {1129+ border = theme.Current().Accent1130+ }1131+1132+ innerWidth := max(width-4, 1)1133+ labelWidth := lipgloss.Width(label) + 11134+ input.SetWidth(max(innerWidth-labelWidth, 1))1135+1136+ body := lipgloss.JoinHorizontal(1137+ lipgloss.Top,1138+ ui.DimItemStyle().Render(label),1139+ " ",1140+ renderSingleLine(input.View(), innerWidth-labelWidth),1141+ )1142+ return lipgloss.NewStyle().1143+ Border(lipgloss.RoundedBorder()).1144+ BorderForeground(border).1145+ Padding(0, 1).1146+ Width(width).1147+ Height(1).1148+ Render(body)1149+}1150+1151+func (s *exportState) renderInlineChoiceField(label, value string, focused bool, width int) string {1152+ border := theme.Current().Border1153+ if focused {1154+ border = theme.Current().Accent1155+ }1156+1157+ innerWidth := max(width-4, 1)1158+ labelWidth := lipgloss.Width(label) + 11159+ valueStyle := lipgloss.NewStyle().Foreground(theme.Current().FG)1160+ if focused {1161+ valueStyle = valueStyle.Foreground(theme.Current().Accent).Bold(true)1162+ }1163+1164+ body := lipgloss.JoinHorizontal(1165+ lipgloss.Top,1166+ ui.DimItemStyle().Render(label),1167+ " ",1168+ renderSingleLine(valueStyle.Render(value), innerWidth-labelWidth),1169+ )1170+ return lipgloss.NewStyle().1171+ Border(lipgloss.RoundedBorder()).1172+ BorderForeground(border).1173+ Padding(0, 1).1174+ Width(width).1175+ Height(1).1176+ Render(body)1177+}1178+1179+func renderSingleLine(value string, width int) string {1180+ if width <= 0 {1181+ return ""1182+ }1183+ return lipgloss.NewStyle().1184+ Width(width).1185+ MaxWidth(width).1186+ Height(1).1187+ MaxHeight(1).1188+ Render(value)1189+}1190+1191+func renderChoiceChip(label string, focused bool) string {1192+ style := lipgloss.NewStyle().1193+ Bold(true).1194+ Padding(0, 1).1195+ Foreground(theme.Current().FG).1196+ Border(lipgloss.NormalBorder()).1197+ BorderForeground(theme.Current().Border)1198+ if focused {1199+ style = style.Foreground(theme.TextOnBG(theme.Current().Accent)).Background(theme.Current().Accent).BorderForeground(theme.Current().Accent)1200+ }1201+ return style.Render(label)1202+}1203+1204+func layoutLabel(layout string) string {1205+ switch layout {1206+ case "duplex-booklet":1207+ return "Booklet"1208+ default:1209+ return "Half Letter"1210+ }1211+}1212+1213+func onOffLabel(enabled bool) string {1214+ if enabled {1215+ return "On"1216+ }1217+ return "Off"1218+}1219+1220+func (s *exportState) renderSummary(width int) string {1221+ total := 01222+ for _, card := range s.cards {1223+ for _, count := range card.Buckets {1224+ total += count1225+ }1226+ }1227+1228+ selected := "none"1229+ seedHint := ""1230+ if len(s.cards) > 0 {1231+ card := s.cards[s.cardIndex]1232+ selected = fmt.Sprintf("%s / %s = %d", card.GameType, bucketNames[s.bucketIndex], card.Buckets[s.bucketIndex])1233+ if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {1234+ seedHint = " selected bucket cannot be seeded"1235+ }1236+ }1237+1238+ button := renderChoiceChip("Export", s.focus == exportFocusSubmit)1239+ totalText := ui.PanelTitle().Render(fmt.Sprintf("Total: %d", total))1240+ detailWidth := max(width-lipgloss.Width(totalText)-lipgloss.Width(button)-4, 12)1241+ detailText := ui.DimItemStyle().Render(truncateLabel("Selected: "+selected+seedHint, detailWidth))1242+ detail := lipgloss.NewStyle().Width(detailWidth).MaxWidth(detailWidth).Render(detailText)1243+ return lipgloss.JoinHorizontal(lipgloss.Top, totalText, " ", detail, " ", button)1244+}1245+1246+var bucketNames = [3]string{"Easy", "Medium", "Hard"}1247+1248+func (s *exportState) renderCards(width, height int) string {1249+ if len(s.cards) == 0 {1250+ return ui.DimItemStyle().Render("No exportable games found.")1251+ }1252+1253+ cols := s.cardColumns(width)1254+ cardWidth := exportCardWidth(width, cols)1255+ start := s.cardRowOffset * cols1256+ end := min(len(s.cards), start+s.visibleCardRows(width, height)*cols)1257+1258+ rows := []string{}1259+ for i := start; i < end; i += cols {1260+ rowEnd := min(i+cols, end)1261+ parts := make([]string, 0, rowEnd-i)1262+ for idx := i; idx < rowEnd; idx++ {1263+ parts = append(parts, s.renderCard(idx, cardWidth))1264+ }1265+ rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, joinCardParts(parts)...))1266+ }1267+1268+ footer := ""1269+ totalRows := max((len(s.cards)+cols-1)/cols, 1)1270+ if totalRows > s.visibleCardRows(width, height) {1271+ footer = ui.DimItemStyle().Render(fmt.Sprintf("rows %d-%d of %d", s.cardRowOffset+1, min(s.cardRowOffset+s.visibleCardRows(width, height), totalRows), totalRows))1272+ }1273+1274+ content := strings.Join(rows, "\n")1275+ if footer != "" {1276+ content = lipgloss.JoinVertical(lipgloss.Left, content, footer)1277+ }1278+ return content1279+}1280+1281+func (s *exportState) renderCard(index, width int) string {1282+ card := s.cards[index]1283+ selected := index == s.cardIndex1284+1285+ border := theme.Current().Border1286+ if selected {1287+ border = theme.Current().Accent1288+ }1289+1290+ titleStyle := ui.PanelTitle()1291+ if selected {1292+ titleStyle = ui.SelectedItemStyle()1293+ }1294+1295+ bucketParts := make([]string, 0, 3)1296+ for bucketIndex, name := range bucketNames {1297+ bucketParts = append(bucketParts, s.renderDifficultyPair(card, selected, bucketIndex, name))1298+ }1299+1300+ total := card.Buckets[0] + card.Buckets[1] + card.Buckets[2]1301+ titleWidth := max(width-10, 6)1302+ title := truncateLabel(card.GameType, titleWidth)1303+ titleLine := lipgloss.JoinHorizontal(1304+ lipgloss.Top,1305+ titleStyle.Width(titleWidth).Render(title),1306+ " ",1307+ ui.DimItemStyle().Render(fmt.Sprintf("%d", total)),1308+ )1309+1310+ body := lipgloss.JoinVertical(1311+ lipgloss.Left,1312+ titleLine,1313+ lipgloss.JoinHorizontal(lipgloss.Top, bucketParts[0], " ", bucketParts[1], " ", bucketParts[2]),1314+ )1315+1316+ return lipgloss.NewStyle().1317+ Border(lipgloss.RoundedBorder()).1318+ BorderForeground(border).1319+ Padding(0, 1).1320+ Width(width).1321+ Render(body)1322+}1323+1324+func (s *exportState) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {1325+ palette := theme.Current()1326+ bg, fg := exportDifficultyColors(bucketIndex, palette)1327+ pairStyle := lipgloss.NewStyle().1328+ Foreground(fg).1329+ Background(bg).1330+ Bold(true).1331+ Padding(0, 0)1332+ if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(bucketIndex) {1333+ pairStyle = pairStyle.Foreground(palette.TextDim).Background(theme.Blend(palette.BG, bg, 0.35))1334+ }1335+1336+ pair := pairStyle.Render(fmt.Sprintf("%s%d", string(bucketName[0]), card.Buckets[bucketIndex]))1337+ if cardSelected && s.focus == exportFocusCards && bucketIndex == s.bucketIndex {1338+ return puzzlegame.CursorStyle().Render(puzzlegame.CursorLeft) + pair + puzzlegame.CursorStyle().Render(puzzlegame.CursorRight)1339+ }1340+ return pair1341+}1342+1343+func (s *exportState) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, screenAction, bool) {1344+ mouse := msg.Mouse()1345+ content := s.view(contentWidth, contentHeight)1346+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)1347+ localX := mouse.X - contentX1348+ localY := mouse.Y - contentY1349+ if localX < 0 || localY < 0 {1350+ return nil, nil, false1351+ }1352+1353+ metrics := s.contentMetrics(contentWidth, contentHeight)1354+ if cmd, handled := s.handleSettingsClick(localX, localY, metrics); handled {1355+ return cmd, nil, true1356+ }1357+ if metrics.SummaryButton.contains(localX, localY) {1358+ s.focus = exportFocusSubmit1359+ return s.applyFocus(), exportSubmitAction{}, true1360+ }1361+ if cmd, handled := s.handleCardClick(localX, localY, metrics); handled {1362+ return cmd, nil, true1363+ }1364+ return nil, nil, false1365+}1366+1367+func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (panelX, panelY, contentX, contentY int) {1368+ panel := ui.Panel("Export", content, exportFooterHint)1369+ panelWidth := lipgloss.Width(panel)1370+ panelHeight := lipgloss.Height(panel)1371+ panelX = max((screenWidth-panelWidth)/2, 0)1372+ panelY = max((screenHeight-panelHeight)/2, 0)1373+1374+ frame := ui.PanelFrame()1375+ contentX = panelX + frame.GetHorizontalFrameSize()/21376+ contentY = panelY + 1 + 2 + 21377+ return panelX, panelY, contentX, contentY1378+}1379+1380+func (s *exportState) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {1381+ mouse := msg.Mouse()1382+ content := s.view(contentWidth, contentHeight)1383+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)1384+ localX := mouse.X - contentX1385+ localY := mouse.Y - contentY1386+ if localX < 0 || localY < 0 {1387+ return false1388+ }1389+ metrics := s.contentMetrics(contentWidth, contentHeight)1390+ if !metrics.CardsRect.contains(localX, localY) {1391+ return false1392+ }1393+ totalRows := max((len(s.cards)+metrics.CardColumns-1)/metrics.CardColumns, 1)1394+ if totalRows <= metrics.VisibleRows {1395+ return false1396+ }1397+ switch mouse.Button {1398+ case tea.MouseWheelDown:1399+ s.cardRowOffset = min(s.cardRowOffset+1, totalRows-metrics.VisibleRows)1400+ case tea.MouseWheelUp:1401+ s.cardRowOffset = max(s.cardRowOffset-1, 0)1402+ default:1403+ return false1404+ }1405+ s.clampSelectionToVisibleRows(metrics.CardColumns, metrics.VisibleRows)1406+ return true1407+}1408+1409+func (s *exportState) clampSelectionToVisibleRows(cols, visibleRows int) {1410+ if len(s.cards) == 0 || cols <= 0 || visibleRows <= 0 {1411+ return1412+ }1413+ row := s.cardIndex / cols1414+ col := s.cardIndex % cols1415+ if row < s.cardRowOffset {1416+ row = s.cardRowOffset1417+ }1418+ maxVisibleRow := s.cardRowOffset + visibleRows - 11419+ if row > maxVisibleRow {1420+ row = maxVisibleRow1421+ }1422+ index := row*cols + col1423+ if index >= len(s.cards) {1424+ index = len(s.cards) - 11425+ }1426+ s.cardIndex = max(index, 0)1427+}1428+1429+func (s *exportState) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1430+ settings := metrics.Settings1431+ switch {1432+ case settings.Title.contains(localX, localY):1433+ return s.focusTextInput(exportFocusTitle, &s.titleInput, settings.Title, "Title", localX), true1434+ case settings.Advert.contains(localX, localY):1435+ return s.focusTextInput(exportFocusAdvert, &s.advertInput, settings.Advert, "Advert", localX), true1436+ case settings.Header.contains(localX, localY):1437+ return s.focusTextarea(settings.Header, localX, localY), true1438+ case settings.Volume.contains(localX, localY):1439+ return s.focusTextInput(exportFocusVolume, &s.volumeInput, settings.Volume, "Volume", localX), true1440+ case settings.Layout.contains(localX, localY):1441+ s.focus = exportFocusLayout1442+ s.cycleLayout(1)1443+ return s.applyFocus(), true1444+ case settings.Seed.contains(localX, localY):1445+ return s.focusTextInput(exportFocusSeed, &s.seedInput, settings.Seed, "Seed", localX), true1446+ case settings.JSONL.contains(localX, localY):1447+ s.focus = exportFocusJSONLToggle1448+ s.toggleJSONL()1449+ return s.applyFocus(), true1450+ case settings.PDFPath.contains(localX, localY):1451+ return s.focusTextInput(exportFocusPDFPath, &s.pdfPathInput, settings.PDFPath, "PDF Path", localX), true1452+ case settings.HasJSONL && settings.JSONLPath.contains(localX, localY):1453+ return s.focusTextInput(exportFocusJSONLPath, &s.jsonlPathInput, settings.JSONLPath, "JSONL Path", localX), true1454+ default:1455+ return nil, false1456+ }1457+}1458+1459+func (s *exportState) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {1460+ s.focus = focus1461+ labelWidth := lipgloss.Width(label) + 11462+ cursorPos := max(localX-rect.X-2-labelWidth, 0)1463+ input.SetCursor(cursorPos)1464+ return s.applyFocus()1465+}1466+1467+func (s *exportState) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {1468+ s.focus = exportFocusHeader1469+ line := max(localY-rect.Y-2, 0)1470+ col := max(localX-rect.X-2, 0)1471+ maxLine := max(s.headerInput.LineCount()-1, 0)1472+ line = min(line, maxLine)1473+ s.headerInput.MoveToBegin()1474+ for i := 0; i < line; i++ {1475+ s.headerInput.CursorDown()1476+ }1477+ s.headerInput.SetCursorColumn(col)1478+ return s.applyFocus()1479+}1480+1481+func (s *exportState) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1482+ if len(s.cards) == 0 {1483+ return nil, false1484+ }1485+ for _, layout := range metrics.CardRects {1486+ if !layout.Rect.contains(localX, localY) {1487+ continue1488+ }1489+ for bucketIndex, bucket := range layout.Buckets {1490+ if bucket.contains(localX, localY) {1491+ return s.selectCardBucket(layout.Index, bucketIndex, metrics), true1492+ }1493+ }1494+ return s.selectCardBucket(layout.Index, s.bucketIndex, metrics), true1495+ }1496+ return nil, false1497+}1498+1499+func (s *exportState) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {1500+ s.focus = exportFocusCards1501+ s.cardIndex = cardIndex1502+ s.bucketIndex = min(max(bucketIndex, 0), len(bucketNames)-1)1503+ s.ensureCardSelectionVisible(metrics.Width, metrics.Height)1504+ return s.applyFocus()1505+}1506+1507+type exportContentMetrics struct {1508+ Width int1509+ Height int1510+ SettingsHeight int1511+ SummaryHeight int1512+ Settings exportSettingsRects1513+ Summary exportRect1514+ SummaryButton exportRect1515+ CardsRect exportRect1516+ CardsY int1517+ CardWidth int1518+ CardHeight int1519+ CardColumns int1520+ VisibleRows int1521+ CardRects []exportCardRects1522+}1523+1524+func exportDifficultyColors(bucketIndex int, palette theme.Palette) (bg, fg color.Color) {1525+ switch bucketIndex {1526+ case 0:1527+ bg = palette.ErrorBG1528+ case 1:1529+ bg = palette.SuccessBG1530+ default:1531+ bg = theme.Blend(palette.BG, palette.Secondary, 0.35)1532+ }1533+ fg = theme.TextOnBG(bg)1534+ return bg, fg1535+}1536+1537+func exportCardWidth(width, cols int) int {1538+ if cols <= 0 {1539+ return width1540+ }1541+ return max((width-(cols-1)*exportCardGap)/cols, exportCardMinWidth)1542+}1543+1544+func joinCardParts(parts []string) []string {1545+ if len(parts) == 0 {1546+ return nil1547+ }1548+ joined := make([]string, 0, len(parts)*2-1)1549+ for i, part := range parts {1550+ if i > 0 {1551+ joined = append(joined, strings.Repeat(" ", exportCardGap))1552+ }1553+ joined = append(joined, part)1554+ }1555+ return joined1556+}1557+1558+func truncateLabel(s string, width int) string {1559+ if width <= 0 {1560+ return ""1561+ }1562+ runes := []rune(s)1563+ if len(runes) <= width {1564+ return s1565+ }1566+ if width == 1 {1567+ return string(runes[:1])1568+ }1569+ return string(runes[:width-1]) + "…"1570+}1571+1572+func (s exportState) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {1573+ sync := s1574+ sync.syncValuesFromInputs()1575+1576+ spec := sync.values.toExactSpec()1577+ spec.Counts = expandCardCounts(sync.cards)1578+ if !sync.values.JSONLEnabled {1579+ spec.JSONLOutputPath = ""1580+ }1581+1582+ cfg := config.ExportConfig{1583+ Title: spec.Title,1584+ Header: spec.Header,1585+ Advert: spec.Advert,1586+ Volume: spec.Volume,1587+ SheetLayout: spec.SheetLayout,1588+ Seed: spec.Seed,1589+ PDFOutputPath: spec.PDFOutputPath,1590+ JSONLEnabled: sync.values.JSONLEnabled,1591+ JSONLOutputPath: sync.values.JSONLOutputPath,1592+ Counts: spec.Counts,1593+ }1594+ return spec, cfg1595+}1596+1597+func (s *exportState) syncValuesFromInputs() {1598+ s.values.Title = s.titleInput.Value()1599+ s.values.Header = s.headerInput.Value()1600+ s.values.Advert = s.advertInput.Value()1601+ s.values.Volume = s.volumeInput.Value()1602+ s.values.Seed = s.seedInput.Value()1603+ s.values.PDFOutputPath = s.pdfPathInput.Value()1604+ s.values.JSONLOutputPath = s.jsonlPathInput.Value()1605+}1606+1607+func (v exportFormValues) toExactSpec() packexport.Spec {1608+ volume, err := strconv.Atoi(strings.TrimSpace(v.Volume))1609+ if err != nil {1610+ volume = -11611+ }1612+1613+ spec := packexport.Spec{1614+ Title: strings.TrimSpace(v.Title),1615+ Header: strings.TrimSpace(v.Header),1616+ Advert: strings.TrimSpace(v.Advert),1617+ Volume: volume,1618+ SheetLayout: strings.TrimSpace(v.SheetLayout),1619+ Seed: strings.TrimSpace(v.Seed),1620+ PDFOutputPath: strings.TrimSpace(v.PDFOutputPath),1621+ Counts: map[puzzle.GameID]map[puzzle.ModeID]int{},1622+ }1623+ if v.JSONLEnabled {1624+ spec.JSONLOutputPath = strings.TrimSpace(v.JSONLOutputPath)1625+ }1626+ return spec1627+}1628+1629+func expandCardCounts(cards []exportGameCard) map[puzzle.GameID]map[puzzle.ModeID]int {1630+ counts := make(map[puzzle.GameID]map[puzzle.ModeID]int, len(cards))1631+ for _, card := range cards {1632+ if _, ok := counts[card.GameID]; !ok {1633+ counts[card.GameID] = make(map[puzzle.ModeID]int)1634+ }1635+ for bucketIndex, total := range card.Buckets {1636+ modes := card.BucketModes[bucketIndex]1637+ if len(modes) == 0 {1638+ continue1639+ }1640+ base := total / len(modes)1641+ remainder := total % len(modes)1642+ for i, mode := range modes {1643+ count := base1644+ if i < remainder {1645+ count++1646+ }1647+ counts[mode.GameID][mode.ModeID] = count1648+ }1649+ }1650+ }1651+ return counts1652+}@@ -0,0 +1,539 @@
1+package app2+3+import (4+ "path/filepath"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "charm.land/lipgloss/v2"10+ "github.com/FelineStateMachine/puzzletea/config"11+ "github.com/FelineStateMachine/puzzletea/packexport"12+ "github.com/FelineStateMachine/puzzletea/puzzle"13+)14+15+func TestHandleExportEnterLoadsPersistedDefaults(t *testing.T) {16+ tmp := t.TempDir()17+ t.Chdir(tmp)18+19+ m := model{20+ cfg: &config.Config{21+ Export: config.ExportConfig{22+ Title: "Saved Sampler",23+ Volume: 3,24+ SheetLayout: "duplex-booklet",25+ PDFOutputPath: filepath.Join(tmp, "saved.pdf"),26+ Counts: map[puzzle.GameID]map[puzzle.ModeID]int{27+ puzzle.CanonicalGameID("Sudoku"): {28+ puzzle.CanonicalModeID("Easy"): 4,29+ },30+ },31+ },32+ },33+ width: 100,34+ height: 30,35+ }36+37+ next, _ := m.handleExportEnter()38+ got := next.(model)39+40+ if got.state != exportView {41+ t.Fatalf("state = %d, want %d", got.state, exportView)42+ }43+ if got.export.values.Title != "Saved Sampler" {44+ t.Fatalf("title = %q, want %q", got.export.values.Title, "Saved Sampler")45+ }46+ if got.export.values.Volume != "3" {47+ t.Fatalf("volume = %q, want %q", got.export.values.Volume, "3")48+ }49+ if got.export.cards[findExportCardIndex(got.export.cards, puzzle.CanonicalGameID("Sudoku"))].Buckets[0] != 4 {50+ t.Fatal("expected persisted count to populate the form")51+ }52+ if !got.export.initialized {53+ t.Fatal("expected export editor to be initialized")54+ }55+}56+57+func TestHandleExportCompleteSuccessShowsSuccessNotice(t *testing.T) {58+ tmp := t.TempDir()59+ t.Chdir(tmp)60+61+ m := model{62+ state: exportRunningView,63+ width: 100,64+ height: 30,65+ export: exportState{66+ jobID: 7,67+ initialized: true,68+ values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),69+ cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),70+ },71+ }72+73+ next, _ := m.handleExportComplete(exportCompleteMsg{74+ jobID: 7,75+ result: packexport.Result{76+ TotalCount: 10,77+ PDFOutputPath: filepath.Join(tmp, "sample.pdf"),78+ },79+ })80+ got := next.(model)81+82+ if got.state != exportView {83+ t.Fatalf("state = %d, want %d", got.state, exportView)84+ }85+ if got.notice.level != noticeLevelSuccess {86+ t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelSuccess)87+ }88+ if got.notice.message == "" {89+ t.Fatal("expected success notice")90+ }91+ if strings.Contains(got.notice.message, ".jsonl") {92+ t.Fatalf("notice message = %q, did not expect jsonl path when jsonl export is disabled", got.notice.message)93+ }94+}95+96+func TestHandleExportCompleteSuccessIncludesJSONLWhenPresent(t *testing.T) {97+ tmp := t.TempDir()98+ t.Chdir(tmp)99+100+ jsonlPath := filepath.Join(tmp, "sample.jsonl")101+ m := model{102+ state: exportRunningView,103+ width: 100,104+ height: 30,105+ export: exportState{106+ jobID: 8,107+ initialized: true,108+ values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),109+ cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),110+ },111+ }112+113+ next, _ := m.handleExportComplete(exportCompleteMsg{114+ jobID: 8,115+ result: packexport.Result{116+ TotalCount: 10,117+ PDFOutputPath: filepath.Join(tmp, "sample.pdf"),118+ JSONLOutputPath: jsonlPath,119+ },120+ })121+ got := next.(model)122+123+ if !strings.Contains(got.notice.message, jsonlPath) {124+ t.Fatalf("notice message = %q, want jsonl output path", got.notice.message)125+ }126+}127+128+func TestHandleExportCompleteFailureShowsErrorNotice(t *testing.T) {129+ tmp := t.TempDir()130+ t.Chdir(tmp)131+132+ m := model{133+ state: exportRunningView,134+ width: 100,135+ height: 30,136+ export: exportState{137+ jobID: 9,138+ initialized: true,139+ values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),140+ cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),141+ },142+ }143+144+ next, _ := m.handleExportComplete(exportCompleteMsg{145+ jobID: 9,146+ err: assertiveError("boom"),147+ })148+ got := next.(model)149+150+ if got.notice.level != noticeLevelError {151+ t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelError)152+ }153+ if got.state != exportView {154+ t.Fatalf("state = %d, want %d", got.state, exportView)155+ }156+}157+158+func TestHandleExportSubmitRejectsInvalidVolumeInput(t *testing.T) {159+ tmp := t.TempDir()160+ spec := packexport.DefaultSpec(tmp)161+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 160)162+ state.volumeInput.SetValue("abc")163+164+ m := model{165+ state: exportView,166+ width: 100,167+ height: 30,168+ export: state,169+ }170+171+ next, _ := m.handleExportSubmit()172+ got := next.(model)173+174+ if got.state != exportView {175+ t.Fatalf("state = %d, want %d", got.state, exportView)176+ }177+ if got.notice.level != noticeLevelError {178+ t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelError)179+ }180+ if !strings.Contains(got.notice.message, "volume") {181+ t.Fatalf("notice message = %q, want volume validation error", got.notice.message)182+ }183+}184+185+func TestExportHeaderInputUsesThreeLineTextarea(t *testing.T) {186+ tmp := t.TempDir()187+ spec := packexport.DefaultSpec(tmp)188+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 160)189+190+ if got := state.headerInput.Height(); got != 3 {191+ t.Fatalf("headerInput.Height() = %d, want 3", got)192+ }193+}194+195+func TestExportCardLayoutTargetsDenseDesktop(t *testing.T) {196+ tmp := t.TempDir()197+ spec := packexport.DefaultSpec(tmp)198+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)199+200+ if got := state.cardColumns(190); got != 5 {201+ t.Fatalf("cardColumns(190) = %d, want 5", got)202+ }203+ if got := state.visibleCardRows(190, 34); got < 3 {204+ t.Fatalf("visibleCardRows(190, 34) = %d, want at least 3", got)205+ }206+}207+208+func TestRenderCardUsesCompactDifficultyPairs(t *testing.T) {209+ tmp := t.TempDir()210+ spec := packexport.DefaultSpec(tmp)211+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)212+ state.focus = exportFocusCards213+ state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Sudoku"))214+ state.bucketIndex = 0215+216+ rendered := state.renderCard(state.cardIndex, exportCardWidth(190, state.cardColumns(190)))217+218+ if !strings.Contains(rendered, "▸") || !strings.Contains(rendered, "◂") {219+ t.Fatal("expected selected difficulty game cursor markers in card")220+ }221+ if !strings.Contains(rendered, "E2") {222+ t.Fatal("expected compact difficulty pair in card")223+ }224+}225+226+func TestExportCardArrowNavigationTraversesBucketsAcrossGames(t *testing.T) {227+ tmp := t.TempDir()228+ spec := packexport.DefaultSpec(tmp)229+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)230+ state.focus = exportFocusCards231+ state.cardIndex = 0232+ state.bucketIndex = 2233+234+ if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyRight}, 190, 30); !handled {235+ t.Fatal("expected right arrow to be handled")236+ }237+ if state.cardIndex != 1 || state.bucketIndex != 0 {238+ t.Fatalf("selection = (%d,%d), want (1,0)", state.cardIndex, state.bucketIndex)239+ }240+241+ if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyLeft}, 190, 30); !handled {242+ t.Fatal("expected left arrow to be handled")243+ }244+ if state.cardIndex != 0 || state.bucketIndex != 2 {245+ t.Fatalf("selection = (%d,%d), want (0,2)", state.cardIndex, state.bucketIndex)246+ }247+}248+249+func TestExportCardDigitsEditSelectedBucket(t *testing.T) {250+ tmp := t.TempDir()251+ spec := packexport.DefaultSpec(tmp)252+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)253+ state.focus = exportFocusCards254+ state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Nurikabe"))255+ state.bucketIndex = 1256+257+ state.cards[state.cardIndex].Buckets[state.bucketIndex] = 0258+ if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: '4', Text: "4"}, 190, 30); !handled {259+ t.Fatal("expected digit key to be handled")260+ }261+ if got := state.cards[state.cardIndex].Buckets[state.bucketIndex]; got != 4 {262+ t.Fatalf("bucket count = %d, want 4", got)263+ }264+265+ if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyBackspace}, 190, 30); !handled {266+ t.Fatal("expected backspace to be handled")267+ }268+ if got := state.cards[state.cardIndex].Buckets[state.bucketIndex]; got != 0 {269+ t.Fatalf("bucket count after backspace = %d, want 0", got)270+ }271+}272+273+func TestRenderSettingsShowsFullJSONLOffLabel(t *testing.T) {274+ tmp := t.TempDir()275+ spec := packexport.DefaultSpec(tmp)276+ spec.JSONLOutputPath = ""277+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)278+279+ rendered := state.renderSettings(190)280+ if !strings.Contains(rendered, "JSONL") || !strings.Contains(rendered, "Off") {281+ t.Fatal("expected JSONL toggle to render the full Off label in settings")282+ }283+}284+285+func TestRenderSummaryIncludesSelectedValue(t *testing.T) {286+ tmp := t.TempDir()287+ spec := packexport.DefaultSpec(tmp)288+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)289+ state.focus = exportFocusCards290+ state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Sudoku"))291+ state.bucketIndex = 0292+293+ rendered := state.renderSummary(190)294+ if !strings.Contains(rendered, "Sudoku / Easy = 2") {295+ t.Fatal("expected summary to include selected game, difficulty, and value")296+ }297+}298+299+func TestExportMouseClickOnSummaryButtonSubmits(t *testing.T) {300+ tmp := t.TempDir()301+ spec := packexport.DefaultSpec(tmp)302+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)303+304+ screenWidth, screenHeight := 220, 40305+ contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)306+ metrics := state.contentMetrics(contentWidth, contentHeight)307+ buttonWidth := lipgloss.Width(renderChoiceChip("Export", false))308+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))309+310+ click := tea.MouseClickMsg{311+ X: contentX + metrics.Width - buttonWidth + 1,312+ Y: contentY + metrics.SettingsHeight,313+ }314+ _, action, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)315+ if !handled {316+ t.Fatal("expected export button click to be handled")317+ }318+ if _, ok := action.(exportSubmitAction); !ok {319+ t.Fatalf("action = %T, want exportSubmitAction", action)320+ }321+}322+323+func TestExportMouseClickFocusesTextFields(t *testing.T) {324+ tmp := t.TempDir()325+ spec := packexport.DefaultSpec(tmp)326+ spec.Seed = "sample-seed"327+ spec.JSONLOutputPath = filepath.Join(tmp, "out", "sample.jsonl")328+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)329+330+ screenWidth, screenHeight := 220, 40331+ contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)332+ metrics := state.contentMetrics(contentWidth, contentHeight)333+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))334+335+ tests := []struct {336+ name string337+ rect exportRect338+ focus exportFocus339+ position func() int340+ }{341+ {"title", metrics.Settings.Title, exportFocusTitle, func() int { return state.titleInput.Position() }},342+ {"advert", metrics.Settings.Advert, exportFocusAdvert, func() int { return state.advertInput.Position() }},343+ {"seed", metrics.Settings.Seed, exportFocusSeed, func() int { return state.seedInput.Position() }},344+ {"pdf", metrics.Settings.PDFPath, exportFocusPDFPath, func() int { return state.pdfPathInput.Position() }},345+ {"jsonl path", metrics.Settings.JSONLPath, exportFocusJSONLPath, func() int { return state.jsonlPathInput.Position() }},346+ }347+348+ for _, tt := range tests {349+ click := tea.MouseClickMsg{350+ X: contentX + tt.rect.X + min(tt.rect.W-2, 14),351+ Y: contentY + tt.rect.Y + 1,352+ }353+ _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)354+ if !handled {355+ t.Fatalf("%s click was not handled", tt.name)356+ }357+ if state.focus != tt.focus {358+ t.Fatalf("%s focus = %v, want %v", tt.name, state.focus, tt.focus)359+ }360+ if got := tt.position(); got <= 0 {361+ t.Fatalf("%s cursor position = %d, want > 0", tt.name, got)362+ }363+ }364+}365+366+func TestExportMouseClickRepositionsTextCursor(t *testing.T) {367+ tmp := t.TempDir()368+ spec := packexport.DefaultSpec(tmp)369+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)370+371+ screenWidth, screenHeight := 220, 40372+ contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)373+ metrics := state.contentMetrics(contentWidth, contentHeight)374+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))375+376+ first := tea.MouseClickMsg{377+ X: contentX + metrics.Settings.Title.X + 8,378+ Y: contentY + metrics.Settings.Title.Y + 1,379+ }380+ second := tea.MouseClickMsg{381+ X: contentX + metrics.Settings.Title.X + 20,382+ Y: contentY + metrics.Settings.Title.Y + 1,383+ }384+ _, _, handled := state.handleMouseClick(first, screenWidth, screenHeight, contentWidth, contentHeight)385+ if !handled {386+ t.Fatal("expected first title click to be handled")387+ }388+ firstPos := state.titleInput.Position()389+ _, _, handled = state.handleMouseClick(second, screenWidth, screenHeight, contentWidth, contentHeight)390+ if !handled {391+ t.Fatal("expected second title click to be handled")392+ }393+ if state.titleInput.Position() <= firstPos {394+ t.Fatalf("title cursor did not move forward: first=%d second=%d", firstPos, state.titleInput.Position())395+ }396+}397+398+func TestExportMouseClickFocusesHeaderTextarea(t *testing.T) {399+ tmp := t.TempDir()400+ spec := packexport.DefaultSpec(tmp)401+ spec.Header = "line one\nline two\nline three"402+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)403+404+ screenWidth, screenHeight := 220, 40405+ contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)406+ metrics := state.contentMetrics(contentWidth, contentHeight)407+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))408+409+ click := tea.MouseClickMsg{410+ X: contentX + metrics.Settings.Header.X + 18,411+ Y: contentY + metrics.Settings.Header.Y + 4,412+ }413+ _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)414+ if !handled {415+ t.Fatal("expected header click to be handled")416+ }417+ if state.focus != exportFocusHeader {418+ t.Fatalf("focus = %v, want exportFocusHeader", state.focus)419+ }420+ if got := state.headerInput.Line(); got != 2 {421+ t.Fatalf("header line = %d, want 2", got)422+ }423+ if got := state.headerInput.LineInfo().CharOffset; got <= 0 {424+ t.Fatalf("header char offset = %d, want > 0", got)425+ }426+}427+428+func TestExportMouseClickTogglesJSONLImmediately(t *testing.T) {429+ tmp := t.TempDir()430+ spec := packexport.DefaultSpec(tmp)431+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)432+433+ screenWidth, screenHeight := 220, 40434+ contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)435+ metrics := state.contentMetrics(contentWidth, contentHeight)436+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))437+438+ click := tea.MouseClickMsg{439+ X: contentX + metrics.Settings.JSONL.X + 4,440+ Y: contentY + metrics.Settings.JSONL.Y + 1,441+ }442+ _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)443+ if !handled {444+ t.Fatal("expected JSONL click to be handled")445+ }446+ if !state.values.JSONLEnabled {447+ t.Fatal("expected JSONL click to toggle enabled")448+ }449+ if state.focus != exportFocusJSONLToggle {450+ t.Fatalf("focus = %v, want exportFocusJSONLToggle", state.focus)451+ }452+}453+454+func TestExportMouseClickCyclesLayoutImmediately(t *testing.T) {455+ tmp := t.TempDir()456+ spec := packexport.DefaultSpec(tmp)457+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)458+459+ screenWidth, screenHeight := 220, 40460+ contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)461+ metrics := state.contentMetrics(contentWidth, contentHeight)462+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))463+464+ click := tea.MouseClickMsg{465+ X: contentX + metrics.Settings.Layout.X + 5,466+ Y: contentY + metrics.Settings.Layout.Y + 1,467+ }468+ _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)469+ if !handled {470+ t.Fatal("expected layout click to be handled")471+ }472+ if state.values.SheetLayout != "duplex-booklet" {473+ t.Fatalf("layout = %q, want %q", state.values.SheetLayout, "duplex-booklet")474+ }475+}476+477+func TestExportMouseClickSelectsCardBucketWithoutChangingCount(t *testing.T) {478+ tmp := t.TempDir()479+ spec := packexport.DefaultSpec(tmp)480+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)481+482+ screenWidth, screenHeight := 220, 40483+ contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)484+ metrics := state.contentMetrics(contentWidth, contentHeight)485+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))486+487+ target := metrics.CardRects[1]488+ before := state.cards[target.Index].Buckets[1]489+ click := tea.MouseClickMsg{490+ X: contentX + target.Buckets[1].X + 1,491+ Y: contentY + target.Buckets[1].Y,492+ }493+ _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)494+ if !handled {495+ t.Fatal("expected card bucket click to be handled")496+ }497+ if state.cardIndex != target.Index || state.bucketIndex != 1 {498+ t.Fatalf("selection = (%d,%d), want (%d,1)", state.cardIndex, state.bucketIndex, target.Index)499+ }500+ if got := state.cards[target.Index].Buckets[1]; got != before {501+ t.Fatalf("bucket count changed on click: got %d want %d", got, before)502+ }503+}504+505+func TestExportMouseWheelScrollsCardRows(t *testing.T) {506+ tmp := t.TempDir()507+ spec := packexport.DefaultSpec(tmp)508+ state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)509+510+ screenWidth, screenHeight := 220, 30511+ contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)512+ metrics := state.contentMetrics(contentWidth, contentHeight)513+ _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))514+515+ if max((len(state.cards)+metrics.CardColumns-1)/metrics.CardColumns, 1) <= metrics.VisibleRows {516+ t.Fatal("test setup expected scrollable card grid")517+ }518+519+ wheel := tea.MouseWheelMsg{520+ X: contentX + metrics.CardsRect.X + 2,521+ Y: contentY + metrics.CardsRect.Y + 1,522+ Button: tea.MouseWheelDown,523+ }524+ if handled := state.handleMouseWheel(wheel, screenWidth, screenHeight, contentWidth, contentHeight); !handled {525+ t.Fatal("expected wheel event to be handled")526+ }527+ if state.cardRowOffset <= 0 {528+ t.Fatalf("cardRowOffset = %d, want > 0", state.cardRowOffset)529+ }530+}531+532+func findExportCardIndex(cards []exportGameCard, gameID puzzle.GameID) int {533+ for i, card := range cards {534+ if card.GameID == gameID {535+ return i536+ }537+ }538+ return 0539+}@@ -1,7 +1,6 @@
11 package app22 33 import (4- "github.com/FelineStateMachine/puzzletea/config"54 "github.com/FelineStateMachine/puzzletea/theme"65 "github.com/FelineStateMachine/puzzletea/ui"76 @@ -57,7 +56,7 @@ func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) {
5756 5857 _ = theme.Apply(item.ItemTitle)5958 m.cfg.Theme = themeName60- if err := m.cfg.Save(config.DefaultPath()); err != nil {59+ if err := m.cfg.Save(m.configPath); err != nil {6160 m = m.setErrorf("Theme changed, but saving config failed: %v", err)6261 }6362 @@ -19,6 +19,7 @@ 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"2223 "charm.land/bubbles/v2/textinput"2324 "charm.land/bubbles/v2/viewport"2425 tea "charm.land/bubbletea/v2"@@ -35,6 +36,7 @@ type viewState int
3536 3637 const (3738 mainMenuActionPlay = "play"39+ mainMenuActionExport = "export"3840 mainMenuActionStats = "stats"3941 mainMenuActionOptions = "options"4042 mainMenuActionQuit = "quit"@@ -51,6 +53,7 @@ const (
5153 5254 var mainMenuItems = []ui.MenuItem{5355 {Action: mainMenuActionPlay, ItemTitle: "Play", Desc: "start or continue a puzzle"},56+ {Action: mainMenuActionExport, ItemTitle: "Export", Desc: "build a printable puzzle pack"},5457 {Action: mainMenuActionStats, ItemTitle: "Stats", Desc: "your progress"},5558 {Action: mainMenuActionOptions, ItemTitle: "Options", Desc: "configure and learn"},5659 {Action: mainMenuActionQuit, ItemTitle: "Quit", Desc: "exit puzzletea"},@@ -68,7 +71,9 @@ const (
6871 seedInputView6972 gameSelectView7073 modeSelectView74+ exportView7175 generatingView76+ exportRunningView7277 gameView7378 continueView7479 weeklyView@@ -141,6 +146,26 @@ type themeState struct {
141146 previous string142147 }143148 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+144169 type debugState struct {145170 enabled bool146171 renderer *glamour.TermRenderer@@ -191,6 +216,7 @@ type model struct {
191216 help helpState192217 stats statsState193218 theme themeState219+ export exportState194220 debug debugState195221 notice noticeState196222 @@ -202,7 +228,8 @@ type model struct {
202228 store *store.Store203229 204230 // Config205- cfg *config.Config231+ cfg *config.Config232+ configPath string206233 }207234 208235 func newSpinner() spinner.Model {@@ -214,33 +241,35 @@ func newSpinner() spinner.Model {
214241 }215242 216243 // InitialModel creates the root TUI model for the main menu.217-func InitialModel(s *store.Store, cfg *config.Config) model {244+func InitialModel(s *store.Store, cfg *config.Config, configPath string) model {218245 r := initDebugRenderer()219246 l := ui.InitCategoryList(gameCategoryItems, "Select Category")220247 mm := ui.NewMainMenu(mainMenuItems)221248 return model{222- state: mainMenuView,223- debug: debugState{renderer: r},224- nav: navigationState{gameSelectList: l, mainMenu: mm},225- spinner: newSpinner(),226- store: s,227- cfg: cfg,249+ state: mainMenuView,250+ debug: debugState{renderer: r},251+ nav: navigationState{gameSelectList: l, mainMenu: mm},252+ spinner: newSpinner(),253+ store: s,254+ cfg: cfg,255+ configPath: configPath,228256 }229257 }230258 231259 // InitialModelWithGame creates a model that starts directly in gameView,232260 // bypassing the menu. Used by CLI flags (--new, --continue).233-func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, activeGameID int64, completionSaved bool) model {261+func InitialModelWithGame(s *store.Store, cfg *config.Config, configPath string, g game.Gamer, activeGameID int64, completionSaved bool) model {234262 r := initDebugRenderer()235263 l := ui.InitCategoryList(gameCategoryItems, "Select Category")236264 mm := ui.NewMainMenu(mainMenuItems)237265 return model{238- state: gameView,239- debug: debugState{renderer: r},240- nav: navigationState{gameSelectList: l, mainMenu: mm},241- spinner: newSpinner(),242- store: s,243- cfg: cfg,266+ state: gameView,267+ debug: debugState{renderer: r},268+ nav: navigationState{gameSelectList: l, mainMenu: mm},269+ spinner: newSpinner(),270+ store: s,271+ cfg: cfg,272+ configPath: configPath,244273 session: sessionState{245274 game: g,246275 activeGameID: activeGameID,@@ -11,7 +11,10 @@ import (
1111 1212 type noticeLevel string1313 14-const noticeLevelError noticeLevel = "error"14+const (15+ noticeLevelError noticeLevel = "error"16+ noticeLevelSuccess noticeLevel = "success"17+)1518 1619 type noticeState struct {1720 level noticeLevel@@ -33,6 +36,15 @@ func (m model) setErrorf(format string, args ...any) model {
3336 return m3437 }3538 39+func (m model) setSuccessf(format string, args ...any) model {40+ message := fmt.Sprintf(format, args...)41+ m.notice = noticeState{42+ level: noticeLevelSuccess,43+ message: message,44+ }45+ return m46+}47+3648 func (m model) appendNotice(content string) string {3749 return appendNoticeContent(m.width, m.notice, content)3850 }@@ -43,6 +55,9 @@ func appendNoticeContent(width int, notice noticeState, content string) string {
4355 }4456 4557 style := ui.ErrorNoticeStyle()58+ if notice.level == noticeLevelSuccess {59+ style = ui.SuccessNoticeStyle()60+ }4661 block := style.4762 MaxWidth(max(width-8, 24)).4863 Render(notice.message)@@ -40,6 +40,10 @@ type openSeedInputAction struct{}
4040 4141 func (openSeedInputAction) isScreenAction() {}4242 43+type openExportAction struct{}44+45+func (openExportAction) isScreenAction() {}46+4347 type backAction struct {4448 target viewState4549 }@@ -94,6 +98,10 @@ type seedConfirmAction struct{}
9498 9599 func (seedConfirmAction) isScreenAction() {}96100 101+type exportSubmitAction struct{}102+103+func (exportSubmitAction) isScreenAction() {}104+97105 type screenModel interface {98106 State() viewState99107 Resize(width, height int) screenModel@@ -142,6 +150,12 @@ func (m model) activeScreen() screenModel {
142150 entry: m.nav.selectedCategory,143151 list: m.nav.modeSelectList,144152 }153+ case exportView:154+ return exportScreen{155+ width: m.width,156+ height: m.height,157+ export: m.export,158+ }145159 case continueView:146160 return continueScreen{147161 width: m.width,@@ -184,6 +198,12 @@ func (m model) activeScreen() screenModel {
184198 height: m.height,185199 spinner: m.spinner,186200 }201+ case exportRunningView:202+ return exportRunningScreen{203+ width: m.width,204+ height: m.height,205+ spinner: m.spinner,206+ }187207 default:188208 return nil189209 }@@ -31,6 +31,8 @@ func (s mainMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)
3131 switch s.menu.SelectedAction() {3232 case mainMenuActionPlay:3333 return s, nil, openPlayMenuAction{}34+ case mainMenuActionExport:35+ return s, nil, openExportAction{}3436 case mainMenuActionStats:3537 return s, nil, openStatsAction{}3638 case mainMenuActionOptions:@@ -8,15 +8,57 @@ import (
88 )99 1010 func TestMainMenuScreenRoutesByStableAction(t *testing.T) {11- screen := mainMenuScreen{12- menu: ui.NewMainMenu([]ui.MenuItem{13- {Action: mainMenuActionPlay, ItemTitle: "Launch", Desc: "custom label"},14- }),11+ tests := []struct {12+ name string13+ item ui.MenuItem14+ assert func(t *testing.T, action screenAction)15+ }{16+ {17+ name: "play",18+ item: ui.MenuItem{Action: mainMenuActionPlay, ItemTitle: "Launch", Desc: "custom label"},19+ assert: func(t *testing.T, action screenAction) {20+ t.Helper()21+ if _, ok := action.(openPlayMenuAction); !ok {22+ t.Fatalf("action = %T, want %T", action, openPlayMenuAction{})23+ }24+ },25+ },26+ {27+ name: "export",28+ item: ui.MenuItem{Action: mainMenuActionExport, ItemTitle: "Print Pack", Desc: "custom label"},29+ assert: func(t *testing.T, action screenAction) {30+ t.Helper()31+ if _, ok := action.(openExportAction); !ok {32+ t.Fatalf("action = %T, want %T", action, openExportAction{})33+ }34+ },35+ },1536 }1637 17- _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})18- if _, ok := action.(openPlayMenuAction); !ok {19- t.Fatalf("action = %T, want %T", action, openPlayMenuAction{})38+ for _, tt := range tests {39+ t.Run(tt.name, func(t *testing.T) {40+ screen := mainMenuScreen{41+ menu: ui.NewMainMenu([]ui.MenuItem{tt.item}),42+ }43+44+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})45+ tt.assert(t, action)46+ })47+ }48+}49+50+func TestMainMenuItemsPlacesExportBetweenPlayAndStats(t *testing.T) {51+ if len(mainMenuItems) < 3 {52+ t.Fatalf("mainMenuItems length = %d, want at least 3", len(mainMenuItems))53+ }54+ if got := mainMenuItems[0].Action; got != mainMenuActionPlay {55+ t.Fatalf("mainMenuItems[0] = %q, want %q", got, mainMenuActionPlay)56+ }57+ if got := mainMenuItems[1].Action; got != mainMenuActionExport {58+ t.Fatalf("mainMenuItems[1] = %q, want %q", got, mainMenuActionExport)59+ }60+ if got := mainMenuItems[2].Action; got != mainMenuActionStats {61+ t.Fatalf("mainMenuItems[2] = %q, want %q", got, mainMenuActionStats)2062 }2163 }2264 @@ -20,6 +20,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2020 case game.SpawnCompleteMsg:2121 next, cmd := m.handleSpawnComplete(m.session.spawnJobID, msg)2222 return next, cmd23+ case exportCompleteMsg:24+ return m.handleExportComplete(msg)2325 case tea.WindowSizeMsg:2426 m = m.handleWindowSize(msg)2527 if m.state != gameView {@@ -89,6 +91,18 @@ func (m model) handleGlobalKey(msg tea.Msg) (model, tea.Cmd, bool) {
8991 return m, nil, true9092 }9193 94+ if m.state == exportRunningView {95+ switch {96+ case key.Matches(keyMsg, rootKeys.Escape):97+ m.cancelActiveExport()98+ m.state = exportView99+ return m.resizeActiveScreen(), nil, true100+ case key.Matches(keyMsg, rootKeys.Quit):101+ return m, tea.Quit, true102+ }103+ return m, nil, true104+ }105+92106 if m.state == gameView {93107 switch {94108 case key.Matches(keyMsg, rootKeys.Enter):@@ -175,6 +189,8 @@ func (m model) handleScreenAction(action screenAction) (model, tea.Cmd) {
175189 return m.resizeActiveScreen(), nil176190 case openDailyAction:177191 return asModel(m.handleDailyPuzzle())192+ case openExportAction:193+ return asModel(m.handleExportEnter())178194 case openWeeklyAction:179195 return asModel(m.enterWeeklyView())180196 case openSeedInputAction:@@ -215,6 +231,8 @@ func (m model) handleScreenAction(action screenAction) (model, tea.Cmd) {
215231 return asModel(m.handleThemeConfirm())216232 case seedConfirmAction:217233 return asModel(m.handleSeedConfirm())234+ case exportSubmitAction:235+ return asModel(m.handleExportSubmit())218236 default:219237 return m, nil220238 }@@ -15,7 +15,7 @@ import (
1515 func (m model) View() tea.View {1616 v := tea.NewView(m.viewContent())1717 v.AltScreen = true18- if m.state == gameView {18+ if m.state == gameView || m.state == exportView {1919 v.MouseMode = tea.MouseModeCellMotion2020 v.KeyboardEnhancements.ReportEventTypes = true2121 }@@ -129,6 +129,7 @@ func snapshotCommandGlobals(t *testing.T) func() {
129129 prevWithSeed := flagWithSeed130130 prevExport := flagExport131131 prevOutput := flagOutput132+ prevExportSpecPath := exportSpecPath132133 prevListAll := listAll133134 prevLaunchNew := launchNewGameFn134135 prevLaunchSeeded := launchSeededGameFn@@ -144,6 +145,7 @@ func snapshotCommandGlobals(t *testing.T) func() {
144145 flagWithSeed = prevWithSeed145146 flagExport = prevExport146147 flagOutput = prevOutput148+ exportSpecPath = prevExportSpecPath147149 listAll = prevListAll148150 launchNewGameFn = prevLaunchNew149151 launchSeededGameFn = prevLaunchSeeded@@ -42,5 +42,5 @@ func continueGame(name string, cfg *config.Config) error {
4242 }4343 4444 completed := rec.Status == store.StatusCompleted45- return runGameProgramFn(s, cfg, g, rec.ID, completed)45+ return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, completed)4646 }@@ -0,0 +1,48 @@
1+package cmd2+3+import (4+ "context"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/packexport"9+ "github.com/spf13/cobra"10+)11+12+var exportSpecPath string13+14+var exportCmd = &cobra.Command{15+ Use: "export --spec <path>",16+ Short: "Generate a printable puzzle pack from a JSON spec",17+ Args: cobra.NoArgs,18+ RunE: func(cmd *cobra.Command, _ []string) error {19+ if strings.TrimSpace(exportSpecPath) == "" {20+ return fmt.Errorf("--spec is required")21+ }22+23+ spec, err := packexport.LoadSpecFile(exportSpecPath)24+ if err != nil {25+ return err26+ }27+28+ result, err := packexport.Run(context.Background(), spec)29+ if err != nil {30+ return err31+ }32+33+ if _, err := fmt.Fprintf(cmd.OutOrStdout(), "wrote %s", result.PDFOutputPath); err != nil {34+ return err35+ }36+ if result.JSONLOutputPath != "" {37+ if _, err := fmt.Fprintf(cmd.OutOrStdout(), " and %s", result.JSONLOutputPath); err != nil {38+ return err39+ }40+ }41+ _, err = fmt.Fprintln(cmd.OutOrStdout())42+ return err43+ },44+}45+46+func init() {47+ exportCmd.Flags().StringVar(&exportSpecPath, "spec", "", "path to a JSON export spec")48+}@@ -7,8 +7,8 @@ import (
77 "time"88 99 "github.com/FelineStateMachine/puzzletea/builtinprint"10+ "github.com/FelineStateMachine/puzzletea/packexport"1011 "github.com/FelineStateMachine/puzzletea/pdfexport"11- "github.com/FelineStateMachine/puzzletea/puzzle"1212 "github.com/FelineStateMachine/puzzletea/registry"1313 1414 "github.com/spf13/cobra"@@ -55,8 +55,7 @@ func runExportPDF(cmd *cobra.Command, args []string) error {
5555 return nil5656 }5757 58- lookup := buildModeDifficultyLookup(registry.Definitions())59- annotateDifficulty(puzzles, lookup)58+ packexport.AnnotatePuzzlesForPrint(puzzles, registry.Definitions())6059 6160 shuffleSeed := strings.TrimSpace(flagPDFShuffleSeed)6261 if shuffleSeed == "" {@@ -114,104 +113,21 @@ func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string,
114113 if err := validatePDFVolume(flagPDFVolume); err != nil {115114 return pdfexport.RenderConfig{}, err116115 }117- sheetLayout, err := parsePDFSheetLayout(flagPDFSheetLayout)118- if err != nil {119- return pdfexport.RenderConfig{}, err120- }121-122- subtitle := strings.TrimSpace(flagPDFTitle)123- if subtitle == "" {124- subtitle = defaultPDFTitle(docs)125- }126-127- cfg := pdfexport.RenderConfig{128- CoverSubtitle: subtitle,129- HeaderText: strings.TrimSpace(flagPDFHeader),130- VolumeNumber: flagPDFVolume,131- AdvertText: flagPDFAdvert,132- GeneratedAt: now,133- ShuffleSeed: shuffleSeed,134- SheetLayout: sheetLayout,135- }136- return cfg, nil137-}138-139-func parsePDFSheetLayout(raw string) (pdfexport.SheetLayout, error) {140- switch strings.ToLower(strings.TrimSpace(raw)) {141- case "", "half-letter":142- return pdfexport.SheetLayoutHalfLetter, nil143- case "duplex-booklet":144- return pdfexport.SheetLayoutDuplexBooklet, nil145- default:146- return pdfexport.SheetLayoutHalfLetter, fmt.Errorf("--sheet-layout must be half-letter or duplex-booklet")147- }148-}149-150-func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[string]float64 {151- lookup := make(map[string]map[string]float64, len(definitions))152-153- for _, def := range definitions {154- titles := []string{}155- for _, mode := range def.Modes {156- title := strings.TrimSpace(mode.Title)157- if title == "" {158- continue159- }160- titles = append(titles, title)161- }162-163- if len(titles) == 0 {164- continue165- }166-167- scores := make(map[string]float64, len(titles))168- if len(titles) == 1 {169- scores[normalizeDifficultyToken(titles[0])] = 0.5170- } else {171- for i, title := range titles {172- scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(titles)-1)173- }174- }175-176- lookup[normalizeDifficultyToken(def.Name)] = scores177- }178-179- return lookup116+ return packexport.BuildRenderConfig(packexport.RenderOptions{117+ Title: defaultIfBlank(flagPDFTitle, defaultPDFTitle(docs)),118+ Header: flagPDFHeader,119+ Advert: flagPDFAdvert,120+ Volume: flagPDFVolume,121+ SheetLayout: flagPDFSheetLayout,122+ ShuffleSeed: shuffleSeed,123+ GeneratedAt: now,124+ }, docs)180125 }181126 182-func annotateDifficulty(puzzles []pdfexport.Puzzle, lookup map[string]map[string]float64) {183- for i := range puzzles {184- mode := normalizeDifficultyToken(puzzles[i].ModeSelection)185- if mode == "" || strings.Contains(mode, "mixed modes") {186- puzzles[i].DifficultyScore = 0.5187- puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium188- puzzles[i].DifficultySource = "mixed-mode fallback"189- continue190- }191-192- category := normalizeDifficultyToken(puzzles[i].Category)193- modes, ok := lookup[category]194- if !ok {195- puzzles[i].DifficultyScore = 0.5196- puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium197- puzzles[i].DifficultySource = "category lookup fallback"198- continue199- }200-201- score, ok := modes[mode]202- if !ok {203- puzzles[i].DifficultyScore = 0.5204- puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium205- puzzles[i].DifficultySource = "mode lookup fallback"206- continue207- }208-209- puzzles[i].DifficultyScore = score210- puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceHigh211- puzzles[i].DifficultySource = "mode-order"127+func defaultIfBlank(value, fallback string) string {128+ value = strings.TrimSpace(value)129+ if value != "" {130+ return value212131 }213-}214-215-func normalizeDifficultyToken(s string) string {216- return puzzle.NormalizeName(s)132+ return fallback217133 }@@ -0,0 +1,61 @@
1+package cmd2+3+import (4+ "bytes"5+ "encoding/json"6+ "os"7+ "path/filepath"8+ "testing"9+10+ "github.com/FelineStateMachine/puzzletea/packexport"11+ "github.com/FelineStateMachine/puzzletea/puzzle"12+ "github.com/spf13/cobra"13+)14+15+func TestExportCmdRunsSpecFile(t *testing.T) {16+ restore := snapshotCommandGlobals(t)17+ defer restore()18+19+ dir := t.TempDir()20+ specPath := filepath.Join(dir, "spec.json")21+ pdfPath := filepath.Join(dir, "sample.pdf")22+ jsonlPath := filepath.Join(dir, "sample.jsonl")23+24+ spec := packexport.Spec{25+ Title: "CLI Export",26+ Volume: 1,27+ SheetLayout: "half-letter",28+ PDFOutputPath: pdfPath,29+ JSONLOutputPath: jsonlPath,30+ Counts: map[puzzle.GameID]map[puzzle.ModeID]int{31+ puzzle.CanonicalGameID("Sudoku"): {32+ puzzle.CanonicalModeID("Easy"): 1,33+ },34+ },35+ }36+ data, err := json.Marshal(spec)37+ if err != nil {38+ t.Fatal(err)39+ }40+ if err := os.WriteFile(specPath, data, 0o644); err != nil {41+ t.Fatal(err)42+ }43+44+ exportSpecPath = specPath45+ var out bytes.Buffer46+ cmd := &cobra.Command{}47+ cmd.SetOut(&out)48+49+ if err := exportCmd.RunE(cmd, nil); err != nil {50+ t.Fatalf("exportCmd.RunE returned error: %v", err)51+ }52+ if out.String() == "" {53+ t.Fatal("expected command output")54+ }55+ if _, err := os.Stat(pdfPath); err != nil {56+ t.Fatalf("expected pdf output: %v", err)57+ }58+ if _, err := os.Stat(jsonlPath); err != nil {59+ t.Fatalf("expected jsonl output: %v", err)60+ }61+}@@ -92,7 +92,7 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
9292 return err9393 }9494 95- return runGameProgramFn(s, cfg, g, rec.ID, false)95+ return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)9696 }9797 9898 func spawnFromMode(spawner game.Spawner, seed string) (game.Gamer, error) {@@ -138,7 +138,7 @@ func launchSeededGame(seed string, cfg *config.Config) error {
138138 return err139139 }140140 141- return runGameProgramFn(s, cfg, g, rec.ID, completed)141+ return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, completed)142142 }143143 144144 // Mode selection uses rendezvous hashing (independent of RNG).@@ -160,5 +160,5 @@ func launchSeededGame(seed string, cfg *config.Config) error {
160160 return err161161 }162162 163- return runGameProgramFn(s, cfg, g, rec.ID, false)163+ return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)164164 }@@ -3,9 +3,7 @@ package cmd
33 import (44 "encoding/json"55 "fmt"6- "io"76 "math/rand/v2"8- "os"97 "path/filepath"108 "strings"119 "time"@@ -13,6 +11,7 @@ import (
1311 "github.com/FelineStateMachine/puzzletea/builtinprint"1412 "github.com/FelineStateMachine/puzzletea/game"1513 "github.com/FelineStateMachine/puzzletea/namegen"14+ "github.com/FelineStateMachine/puzzletea/packexport"1615 "github.com/FelineStateMachine/puzzletea/pdfexport"1716 "github.com/FelineStateMachine/puzzletea/registry"1817 "github.com/FelineStateMachine/puzzletea/resolve"@@ -187,33 +186,15 @@ func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error)
187186 }188187 189188 func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {190- var b strings.Builder191- for _, record := range records {192- data, err := json.Marshal(record)193- if err != nil {194- return fmt.Errorf("encode jsonl record: %w", err)195- }196- b.Write(data)197- b.WriteByte('\n')189+ content, err := packexport.EncodeJSONL(records)190+ if err != nil {191+ return err198192 }199-200- content := b.String()201193 if strings.TrimSpace(path) == "" {202- if _, err := io.WriteString(cmd.OutOrStdout(), content); err != nil {194+ if _, err := cmd.OutOrStdout().Write(content); err != nil {203195 return fmt.Errorf("write export jsonl to stdout: %w", err)204196 }205197 return nil206198 }207-208- dir := filepath.Dir(path)209- if dir != "" && dir != "." {210- if err := os.MkdirAll(dir, 0o755); err != nil {211- return fmt.Errorf("create output directory: %w", err)212- }213- }214-215- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {216- return fmt.Errorf("write output jsonl: %w", err)217- }218- return nil199+ return packexport.WriteJSONL(path, records)219200 }@@ -68,7 +68,7 @@ var RootCmd = &cobra.Command{
6868 return err6969 }7070 defer s.Close()71- p := tea.NewProgram(app.InitialModel(s, cfg))71+ p := tea.NewProgram(app.InitialModel(s, cfg, activeConfigPath()))7272 _, err = p.Run()7373 return err7474 },@@ -81,20 +81,23 @@ func init() {
8181 RootCmd.PersistentFlags().StringVar(&flagConfigPath, "config", "", "path to config file (default: ~/.puzzletea/config.json)")8282 RootCmd.PersistentFlags().StringVar(&flagTheme, "theme", "", "color theme name (overrides config)")8383 84- RootCmd.AddCommand(newCmd, continueCmd, listCmd, exportPDFCmd, testCmd)84+ RootCmd.AddCommand(newCmd, continueCmd, listCmd, exportCmd, exportPDFCmd, testCmd)8585 }8686 8787 func loadActiveConfig() *config.Config {88- return loadConfig(flagConfigPath)88+ return loadConfig(activeConfigPath())89+}90+91+func activeConfigPath() string {92+ if flagConfigPath == "" {93+ return config.DefaultPath()94+ }95+ return flagConfigPath8996 }9097 9198 // loadConfig reads the config file and applies the active theme. The --theme9299 // flag takes precedence over the persisted setting.93100 func loadConfig(configPath string) *config.Config {94- if configPath == "" {95- configPath = config.DefaultPath()96- }97-98101 cfg, err := config.Load(configPath)99102 if err != nil {100103 log.Printf("warning: %v (using defaults)", err)@@ -14,11 +14,12 @@ var runGameProgramFn = runGameProgram
1414 func runGameProgram(1515 s *store.Store,1616 cfg *config.Config,17+ configPath string,1718 g game.Gamer,1819 activeGameID int64,1920 completionSaved bool,2021 ) error {21- m := app.InitialModelWithGame(s, cfg, g, activeGameID, completionSaved)22+ m := app.InitialModelWithGame(s, cfg, configPath, g, activeGameID, completionSaved)2223 p := tea.NewProgram(m)2324 _, err := p.Run()2425 return err@@ -9,12 +9,29 @@ import (
99 "fmt"1010 "os"1111 "path/filepath"12+13+ "github.com/FelineStateMachine/puzzletea/puzzle"1214 )1315 1416 // Config holds all user-configurable settings.1517 type Config struct {16- Theme string `json:"theme,omitempty"` // theme name; empty = default17- DBPath string `json:"db_path,omitempty"` // database path; empty = ~/.puzzletea/history.db18+ Theme string `json:"theme,omitempty"` // theme name; empty = default19+ DBPath string `json:"db_path,omitempty"` // database path; empty = ~/.puzzletea/history.db20+ Export ExportConfig `json:"export,omitempty"` // last-used export settings21+}22+23+// ExportConfig stores the app's last-used export form values.24+type ExportConfig struct {25+ Title string `json:"title,omitempty"`26+ Header string `json:"header,omitempty"`27+ Advert string `json:"advert,omitempty"`28+ Volume int `json:"volume,omitempty"`29+ SheetLayout string `json:"sheet_layout,omitempty"`30+ Seed string `json:"seed,omitempty"`31+ PDFOutputPath string `json:"pdf_output_path,omitempty"`32+ JSONLEnabled bool `json:"jsonl_enabled,omitempty"`33+ JSONLOutputPath string `json:"jsonl_output_path,omitempty"`34+ Counts map[puzzle.GameID]map[puzzle.ModeID]int `json:"counts,omitempty"`1835 }1936 2037 // Default returns a Config with all settings at their zero values (the@@ -4,6 +4,8 @@ import (
44 "os"55 "path/filepath"66 "testing"7+8+ "github.com/FelineStateMachine/puzzletea/puzzle"79 )810 911 // --- Load / Save round-trip (P0) ---@@ -36,6 +38,45 @@ func TestSaveAndLoad(t *testing.T) {
3638 }3739 }3840 41+func TestSaveAndLoadExportConfig(t *testing.T) {42+ dir := t.TempDir()43+ path := filepath.Join(dir, "config.json")44+45+ cfg := &Config{46+ Theme: "Dracula",47+ Export: ExportConfig{48+ Title: "Sampler",49+ Volume: 2,50+ SheetLayout: "duplex-booklet",51+ PDFOutputPath: filepath.Join(dir, "sample.pdf"),52+ JSONLEnabled: true,53+ JSONLOutputPath: filepath.Join(dir, "sample.jsonl"),54+ Counts: map[puzzle.GameID]map[puzzle.ModeID]int{55+ puzzle.CanonicalGameID("Sudoku"): {56+ puzzle.CanonicalModeID("Easy"): 2,57+ },58+ },59+ },60+ }61+ if err := cfg.Save(path); err != nil {62+ t.Fatalf("Save failed: %v", err)63+ }64+65+ loaded, err := Load(path)66+ if err != nil {67+ t.Fatalf("Load failed: %v", err)68+ }69+ if loaded.Export.Title != cfg.Export.Title {70+ t.Fatalf("loaded export title = %q, want %q", loaded.Export.Title, cfg.Export.Title)71+ }72+ if !loaded.Export.JSONLEnabled {73+ t.Fatal("expected jsonl toggle to round-trip")74+ }75+ if got := loaded.Export.Counts[puzzle.CanonicalGameID("Sudoku")][puzzle.CanonicalModeID("Easy")]; got != 2 {76+ t.Fatalf("loaded export count = %d, want 2", got)77+ }78+}79+3980 func TestSaveCreatesDirectory(t *testing.T) {4081 dir := t.TempDir()4182 path := filepath.Join(dir, "nested", "deep", "config.json")@@ -15,9 +15,14 @@ require (
1515 github.com/alecthomas/chroma/v2 v2.14.0 // indirect1616 github.com/atotto/clipboard v0.1.4 // indirect1717 github.com/aymerick/douceur v0.2.0 // indirect18+ github.com/catppuccin/go v0.3.0 // indirect19+ github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect20+ github.com/charmbracelet/bubbletea v1.3.6 // indirect21+ github.com/charmbracelet/huh v1.0.0 // indirect1822 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect1923 github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect2024 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect25+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect2126 github.com/charmbracelet/x/termios v0.1.1 // indirect2227 github.com/charmbracelet/x/windows v0.2.2 // indirect2328 github.com/clipperhouse/displaywidth v0.9.0 // indirect@@ -25,10 +30,14 @@ require (
2530 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect2631 github.com/dlclark/regexp2 v1.11.0 // indirect2732 github.com/dustin/go-humanize v1.0.1 // indirect33+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect2834 github.com/google/uuid v1.6.0 // indirect2935 github.com/gorilla/css v1.0.1 // indirect3036 github.com/inconshreveable/mousetrap v1.1.0 // indirect37+ github.com/mattn/go-localereader v0.0.1 // indirect3138 github.com/microcosm-cc/bluemonday v1.0.27 // indirect39+ github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect40+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect3241 github.com/muesli/reflow v0.3.0 // indirect3342 github.com/ncruces/go-strftime v1.0.0 // indirect3443 github.com/phpdave11/gofpdi v1.0.13 // indirect@@ -20,10 +20,18 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
2020 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=2121 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=2222 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=23+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=24+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=25+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=26+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=27+github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=28+github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=2329 github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=2430 github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=2531 github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=2632 github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=33+github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=34+github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=2735 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=2836 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=2937 github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=@@ -36,6 +44,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g
3644 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=3745 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=3846 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=47+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=48+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=3949 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=4050 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=4151 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=@@ -53,6 +63,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK
5363 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=5464 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=5565 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=66+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=67+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=5668 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=5769 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=5870 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=@@ -71,11 +83,17 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
7183 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=7284 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=7385 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=86+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=87+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=7488 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=7589 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=7690 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=7791 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=7892 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=93+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=94+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=95+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=96+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=7997 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=8098 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=8199 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=@@ -118,6 +136,7 @@ golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
118136 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=119137 golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=120138 golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=139+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=121140 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=122141 golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=123142 golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=@@ -1,5 +1,5 @@
11 version := `git describe --tags --always --dirty 2>/dev/null || echo "dev"`2-ldflags := "-X github.com/FelineStateMachine/puzzletea/cmd.Version=" + version2+ldflags := "-X github.com/FelineStateMachine/puzzletea/cmd.Version=" + version + " -X github.com/FelineStateMachine/puzzletea/packexport.Version=" + version33 44 # Build the binary (default recipe).55 build:@@ -0,0 +1,176 @@
1+package packexport2+3+import (4+ "context"5+ "errors"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/puzzletea/pdfexport"13+ "github.com/FelineStateMachine/puzzletea/puzzle"14+)15+16+func TestRunDeterministicWithFixedSpecAndSeed(t *testing.T) {17+ restore := snapshotNowFn(t, time.Date(2026, 3, 22, 18, 0, 0, 0, time.UTC))18+ defer restore()19+20+ dir := t.TempDir()21+ specA := smallTestSpec(dir, "a")22+ specB := smallTestSpec(dir, "b")23+24+ if _, err := Run(t.Context(), specA); err != nil {25+ t.Fatalf("first run failed: %v", err)26+ }27+ if _, err := Run(t.Context(), specB); err != nil {28+ t.Fatalf("second run failed: %v", err)29+ }30+31+ jsonlA, err := os.ReadFile(specA.JSONLOutputPath)32+ if err != nil {33+ t.Fatal(err)34+ }35+ jsonlB, err := os.ReadFile(specB.JSONLOutputPath)36+ if err != nil {37+ t.Fatal(err)38+ }39+ if string(jsonlA) != string(jsonlB) {40+ t.Fatal("expected identical jsonl output for identical spec and seed")41+ }42+43+ info, err := os.Stat(specA.PDFOutputPath)44+ if err != nil {45+ t.Fatal(err)46+ }47+ if info.Size() == 0 {48+ t.Fatal("expected non-empty pdf output")49+ }50+}51+52+func TestRunSkipsZeroCountModes(t *testing.T) {53+ restore := snapshotNowFn(t, time.Date(2026, 3, 22, 18, 0, 0, 0, time.UTC))54+ defer restore()55+56+ dir := t.TempDir()57+ spec := smallTestSpec(dir, "single")58+ spec.Counts[puzzle.CanonicalGameID("Word Search")][puzzle.CanonicalModeID("Easy 10x10")] = 059+60+ result, err := Run(t.Context(), spec)61+ if err != nil {62+ t.Fatalf("run failed: %v", err)63+ }64+ if result.TotalCount != 1 {65+ t.Fatalf("TotalCount = %d, want 1", result.TotalCount)66+ }67+68+ data, err := os.ReadFile(spec.JSONLOutputPath)69+ if err != nil {70+ t.Fatal(err)71+ }72+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")73+ if got := len(lines); got != 1 {74+ t.Fatalf("jsonl line count = %d, want 1", got)75+ }76+ if strings.Contains(string(data), "Word Search") {77+ t.Fatal("did not expect zero-count mode to appear in output")78+ }79+}80+81+func TestValidateSpecRejectsUnknownTargets(t *testing.T) {82+ dir := t.TempDir()83+84+ tests := []struct {85+ name string86+ spec Spec87+ want string88+ }{89+ {90+ name: "unknown game",91+ spec: func() Spec {92+ spec := smallTestSpec(dir, "unknown-game")93+ spec.Counts = zeroCounts()94+ spec.Counts[puzzle.GameID("missing-game")] = map[puzzle.ModeID]int{95+ puzzle.ModeID("missing-mode"): 1,96+ }97+ return spec98+ }(),99+ want: "not a known export target",100+ },101+ {102+ name: "unknown mode",103+ spec: func() Spec {104+ spec := smallTestSpec(dir, "unknown-mode")105+ spec.Counts = zeroCounts()106+ spec.Counts[puzzle.CanonicalGameID("Sudoku")][puzzle.ModeID("missing-mode")] = 1107+ return spec108+ }(),109+ want: "not a known export mode",110+ },111+ }112+113+ for _, tt := range tests {114+ t.Run(tt.name, func(t *testing.T) {115+ err := ValidateSpec(tt.spec)116+ if err == nil {117+ t.Fatal("expected validation error")118+ }119+ if !strings.Contains(err.Error(), tt.want) {120+ t.Fatalf("error = %q, want substring %q", err.Error(), tt.want)121+ }122+ })123+ }124+}125+126+func TestRunDoesNotPublishOutputsAfterCancellation(t *testing.T) {127+ restoreNow := snapshotNowFn(t, time.Date(2026, 3, 22, 18, 0, 0, 0, time.UTC))128+ defer restoreNow()129+130+ prevWritePDF := writePDFFn131+ t.Cleanup(func() {132+ writePDFFn = prevWritePDF133+ })134+135+ dir := t.TempDir()136+ spec := smallTestSpec(dir, "canceled")137+ ctx, cancel := context.WithCancel(t.Context())138+ writePDFFn = func(outputPath string, _ []pdfexport.PackDocument, _ []pdfexport.Puzzle, _ pdfexport.RenderConfig) error {139+ if err := os.WriteFile(outputPath, []byte("%PDF-test"), 0o644); err != nil {140+ return err141+ }142+ cancel()143+ return nil144+ }145+146+ _, err := Run(ctx, spec)147+ if !errors.Is(err, context.Canceled) {148+ t.Fatalf("Run error = %v, want context.Canceled", err)149+ }150+ if _, err := os.Stat(spec.PDFOutputPath); !errors.Is(err, os.ErrNotExist) {151+ t.Fatalf("expected no published pdf output, stat err = %v", err)152+ }153+ if _, err := os.Stat(spec.JSONLOutputPath); !errors.Is(err, os.ErrNotExist) {154+ t.Fatalf("expected no published jsonl output, stat err = %v", err)155+ }156+}157+158+func snapshotNowFn(t *testing.T, now time.Time) func() {159+ t.Helper()160+161+ prev := nowFn162+ nowFn = func() time.Time { return now }163+ return func() { nowFn = prev }164+}165+166+func smallTestSpec(dir, suffix string) Spec {167+ spec := DefaultSpec(dir)168+ spec.Counts = zeroCounts()169+ spec.Title = "Deterministic Sampler"170+ spec.Seed = "packexport-test-seed"171+ spec.PDFOutputPath = filepath.Join(dir, "pack-"+suffix+".pdf")172+ spec.JSONLOutputPath = filepath.Join(dir, "pack-"+suffix+".jsonl")173+ spec.Counts[puzzle.CanonicalGameID("Sudoku")][puzzle.CanonicalModeID("Easy")] = 1174+ spec.Counts[puzzle.CanonicalGameID("Word Search")][puzzle.CanonicalModeID("Easy 10x10")] = 1175+ return spec176+}@@ -0,0 +1,123 @@
1+package packexport2+3+import (4+ "fmt"5+ "strings"6+ "time"7+8+ "github.com/FelineStateMachine/puzzletea/pdfexport"9+ "github.com/FelineStateMachine/puzzletea/puzzle"10+)11+12+type RenderOptions struct {13+ Title string14+ Header string15+ Advert string16+ Volume int17+ SheetLayout string18+ ShuffleSeed string19+ GeneratedAt time.Time20+}21+22+func BuildRenderConfig(options RenderOptions, _ []pdfexport.PackDocument) (pdfexport.RenderConfig, error) {23+ if options.Volume < 1 {24+ return pdfexport.RenderConfig{}, fmt.Errorf("volume must be >= 1")25+ }26+27+ layout, err := parseSheetLayout(options.SheetLayout)28+ if err != nil {29+ return pdfexport.RenderConfig{}, err30+ }31+32+ subtitle := strings.TrimSpace(options.Title)33+ if subtitle == "" {34+ subtitle = "PuzzleTea Mixed Puzzle Pack"35+ }36+37+ cfg := pdfexport.RenderConfig{38+ CoverSubtitle: subtitle,39+ HeaderText: strings.TrimSpace(options.Header),40+ VolumeNumber: options.Volume,41+ AdvertText: strings.TrimSpace(options.Advert),42+ GeneratedAt: options.GeneratedAt,43+ ShuffleSeed: strings.TrimSpace(options.ShuffleSeed),44+ SheetLayout: layout,45+ }46+ if cfg.AdvertText == "" {47+ cfg.AdvertText = DefaultAdvert48+ }49+ return cfg, nil50+}51+52+func AnnotatePuzzlesForPrint(puzzles []pdfexport.Puzzle, definitions []puzzle.Definition) {53+ lookup := buildModeDifficultyLookup(definitions)54+ for i := range puzzles {55+ mode := normalizeDifficultyToken(puzzles[i].ModeSelection)56+ if mode == "" || strings.Contains(mode, "mixed modes") {57+ puzzles[i].DifficultyScore = 0.558+ puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium59+ puzzles[i].DifficultySource = "mixed-mode fallback"60+ continue61+ }62+63+ category := normalizeDifficultyToken(puzzles[i].Category)64+ modes, ok := lookup[category]65+ if !ok {66+ puzzles[i].DifficultyScore = 0.567+ puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium68+ puzzles[i].DifficultySource = "category lookup fallback"69+ continue70+ }71+72+ score, ok := modes[mode]73+ if !ok {74+ puzzles[i].DifficultyScore = 0.575+ puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium76+ puzzles[i].DifficultySource = "mode lookup fallback"77+ continue78+ }79+80+ puzzles[i].DifficultyScore = score81+ puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceHigh82+ puzzles[i].DifficultySource = "mode-order"83+ }84+}85+86+func parseSheetLayout(raw string) (pdfexport.SheetLayout, error) {87+ switch strings.ToLower(strings.TrimSpace(raw)) {88+ case "", "half-letter":89+ return pdfexport.SheetLayoutHalfLetter, nil90+ case "duplex-booklet":91+ return pdfexport.SheetLayoutDuplexBooklet, nil92+ default:93+ return pdfexport.SheetLayoutHalfLetter, fmt.Errorf("--sheet-layout must be half-letter or duplex-booklet")94+ }95+}96+97+func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[string]float64 {98+ lookup := make(map[string]map[string]float64, len(definitions))99+ for _, def := range definitions {100+ titles := []string{}101+ for _, mode := range def.Modes {102+ title := strings.TrimSpace(mode.Title)103+ if title == "" {104+ continue105+ }106+ titles = append(titles, title)107+ }108+ if len(titles) == 0 {109+ continue110+ }111+112+ scores := make(map[string]float64, len(titles))113+ if len(titles) == 1 {114+ scores[normalizeDifficultyToken(titles[0])] = 0.5115+ } else {116+ for i, title := range titles {117+ scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(titles)-1)118+ }119+ }120+ lookup[normalizeDifficultyToken(def.Name)] = scores121+ }122+ return lookup123+}@@ -0,0 +1,195 @@
1+package packexport2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "math/rand/v2"9+ "os"10+ "path/filepath"11+ "strings"12+13+ "github.com/FelineStateMachine/puzzletea/game"14+ "github.com/FelineStateMachine/puzzletea/pdfexport"15+ "github.com/FelineStateMachine/puzzletea/puzzle"16+ "github.com/FelineStateMachine/puzzletea/resolve"17+)18+19+var writePDFFn = pdfexport.WritePDF20+21+type randState struct {22+ rng *rand.Rand23+}24+25+func newRandState(seed string) *randState {26+ return &randState{rng: resolve.RNGFromString(seed)}27+}28+29+func (r *randState) RNG() *rand.Rand {30+ if r == nil {31+ return nil32+ }33+ return r.rng34+}35+36+func spawnSaveData(ctx context.Context, selection modeSelection, state *randState) (json.RawMessage, error) {37+ var (38+ puzzleGame game.Gamer39+ err error40+ )41+42+ if state != nil {43+ if selection.seededSpawner == nil {44+ return nil, fmt.Errorf("mode does not support deterministic spawning")45+ }46+ rng := state.RNG()47+ if cancellable, ok := selection.seededSpawner.(game.CancellableSeededSpawner); ok {48+ puzzleGame, err = cancellable.SpawnSeededContext(ctx, rng)49+ } else {50+ puzzleGame, err = selection.seededSpawner.SpawnSeeded(rng)51+ }52+ } else if cancellable, ok := selection.spawner.(game.CancellableSpawner); ok {53+ puzzleGame, err = cancellable.SpawnContext(ctx)54+ } else {55+ puzzleGame, err = selection.spawner.Spawn()56+ }57+ if err != nil {58+ return nil, err59+ }60+61+ save, err := puzzleGame.GetSave()62+ if err != nil {63+ return nil, fmt.Errorf("serialize puzzle: %w", err)64+ }65+ if !json.Valid(save) {66+ return nil, fmt.Errorf("save payload is not valid json")67+ }68+69+ return append(json.RawMessage(nil), save...), nil70+}71+72+func WriteJSONL(path string, records []pdfexport.JSONLRecord) error {73+ content, err := EncodeJSONL(records)74+ if err != nil {75+ return err76+ }77+78+ dir := filepath.Dir(path)79+ if dir != "" && dir != "." {80+ if err := os.MkdirAll(dir, 0o755); err != nil {81+ return fmt.Errorf("create output directory: %w", err)82+ }83+ }84+85+ if err := os.WriteFile(path, content, 0o644); err != nil {86+ return fmt.Errorf("write output jsonl: %w", err)87+ }88+ return nil89+}90+91+type stagedOutput struct {92+ targetPath string93+ tempPath string94+}95+96+func stageJSONL(path string, records []pdfexport.JSONLRecord) (stagedOutput, error) {97+ content, err := EncodeJSONL(records)98+ if err != nil {99+ return stagedOutput{}, err100+ }101+102+ staged, err := newStagedOutput(path)103+ if err != nil {104+ return stagedOutput{}, err105+ }106+ if err := os.WriteFile(staged.tempPath, content, 0o644); err != nil {107+ staged.Cleanup()108+ return stagedOutput{}, fmt.Errorf("write output jsonl: %w", err)109+ }110+ return staged, nil111+}112+113+func stagePDF(path string, docs []pdfexport.PackDocument, puzzles []pdfexport.Puzzle, cfg pdfexport.RenderConfig) (stagedOutput, error) {114+ staged, err := newStagedOutput(path)115+ if err != nil {116+ return stagedOutput{}, err117+ }118+ if err := writePDFFn(staged.tempPath, docs, puzzles, cfg); err != nil {119+ staged.Cleanup()120+ return stagedOutput{}, err121+ }122+ return staged, nil123+}124+125+func newStagedOutput(targetPath string) (stagedOutput, error) {126+ dir := filepath.Dir(targetPath)127+ if dir != "" && dir != "." {128+ if err := os.MkdirAll(dir, 0o755); err != nil {129+ return stagedOutput{}, fmt.Errorf("create output directory: %w", err)130+ }131+ }132+133+ tempFile, err := os.CreateTemp(dir, ".puzzletea-export-*"+filepath.Ext(targetPath))134+ if err != nil {135+ return stagedOutput{}, fmt.Errorf("create temporary output file: %w", err)136+ }137+ tempPath := tempFile.Name()138+ if err := tempFile.Close(); err != nil {139+ return stagedOutput{}, fmt.Errorf("close temporary output file: %w", err)140+ }141+ if err := os.Remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {142+ return stagedOutput{}, fmt.Errorf("prepare temporary output file: %w", err)143+ }144+145+ return stagedOutput{146+ targetPath: targetPath,147+ tempPath: tempPath,148+ }, nil149+}150+151+func (s stagedOutput) Cleanup() {152+ if s.tempPath == "" {153+ return154+ }155+ _ = os.Remove(s.tempPath)156+}157+158+func (s stagedOutput) Publish() error {159+ if err := os.Rename(s.tempPath, s.targetPath); err == nil {160+ return nil161+ }162+163+ if err := os.Remove(s.targetPath); err != nil && !errors.Is(err, os.ErrNotExist) {164+ return fmt.Errorf("replace output file: %w", err)165+ }166+ if err := os.Rename(s.tempPath, s.targetPath); err != nil {167+ return fmt.Errorf("publish output file: %w", err)168+ }169+ return nil170+}171+172+func EncodeJSONL(records []pdfexport.JSONLRecord) ([]byte, error) {173+ var b strings.Builder174+ for _, record := range records {175+ line, err := json.Marshal(record)176+ if err != nil {177+ return nil, fmt.Errorf("encode jsonl record: %w", err)178+ }179+ b.Write(line)180+ b.WriteByte('\n')181+ }182+ return []byte(b.String()), nil183+}184+185+func flattenPuzzles(docs []pdfexport.PackDocument) []pdfexport.Puzzle {186+ puzzles := []pdfexport.Puzzle{}187+ for _, doc := range docs {188+ puzzles = append(puzzles, doc.Puzzles...)189+ }190+ return puzzles191+}192+193+func normalizeDifficultyToken(s string) string {194+ return puzzle.NormalizeName(s)195+}@@ -0,0 +1,441 @@
1+package packexport2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "os"8+ "path/filepath"9+ "slices"10+ "strings"11+ "time"12+13+ "github.com/FelineStateMachine/puzzletea/builtinprint"14+ "github.com/FelineStateMachine/puzzletea/game"15+ "github.com/FelineStateMachine/puzzletea/namegen"16+ "github.com/FelineStateMachine/puzzletea/pdfexport"17+ "github.com/FelineStateMachine/puzzletea/puzzle"18+ "github.com/FelineStateMachine/puzzletea/registry"19+ "github.com/FelineStateMachine/puzzletea/resolve"20+)21+22+// Version is embedded at build time.23+var Version = "dev"24+25+var nowFn = time.Now26+27+const (28+ DefaultTitle = "PuzzleTea Sampler"29+ DefaultHeader = "A small mixed pack that highlights a few PuzzleTea puzzle styles."30+ DefaultAdvert = "Find more puzzles at github.com/FelineStateMachine/puzzletea"31+ DefaultSheetLayout = "half-letter"32+)33+34+type Spec struct {35+ Title string `json:"title,omitempty"`36+ Header string `json:"header,omitempty"`37+ Advert string `json:"advert,omitempty"`38+ Volume int `json:"volume,omitempty"`39+ SheetLayout string `json:"sheet_layout,omitempty"`40+ Seed string `json:"seed,omitempty"`41+ PDFOutputPath string `json:"pdf_output_path"`42+ JSONLOutputPath string `json:"jsonl_output_path,omitempty"`43+ Counts map[puzzle.GameID]map[puzzle.ModeID]int `json:"counts"`44+}45+46+type Result struct {47+ TotalCount int48+ PDFOutputPath string49+ JSONLOutputPath string50+}51+52+type GameCatalog struct {53+ GameID puzzle.GameID54+ GameType string55+ Modes []ModeCatalog56+}57+58+type ModeCatalog struct {59+ GameID puzzle.GameID60+ GameType string61+ ModeID puzzle.ModeID62+ ModeTitle string63+ Seeded bool64+}65+66+type modeSelection struct {67+ gameID puzzle.GameID68+ gameType string69+ modeID puzzle.ModeID70+ modeTitle string71+ count int72+ spawner game.Spawner73+ seededSpawner game.SeededSpawner74+}75+76+type runPlan struct {77+ spec Spec78+ selections []modeSelection79+}80+81+func DefaultSpec(cwd string) Spec {82+ if strings.TrimSpace(cwd) == "" {83+ cwd = "."84+ }85+86+ spec := Spec{87+ Title: DefaultTitle,88+ Header: DefaultHeader,89+ Advert: DefaultAdvert,90+ Volume: 1,91+ SheetLayout: DefaultSheetLayout,92+ PDFOutputPath: filepath.Join(cwd, "out", "puzzletea-export.pdf"),93+ Counts: zeroCounts(),94+ }95+96+ setDefaultCount(spec.Counts, "Sudoku", "Easy", 2)97+ setDefaultCount(spec.Counts, "Word Search", "Easy 10x10", 2)98+ setDefaultCount(spec.Counts, "Hitori", "Easy", 1)99+ setDefaultCount(spec.Counts, "Nonogram", "Standard", 1)100+ setDefaultCount(spec.Counts, "Nurikabe", "Easy", 1)101+ setDefaultCount(spec.Counts, "Takuzu", "Easy", 1)102+ setDefaultCount(spec.Counts, "Shikaku", "Easy 7x7", 1)103+ setDefaultCount(spec.Counts, "Spell Puzzle", "Easy", 1)104+105+ return spec106+}107+108+func ExportCatalog() []GameCatalog {109+ entries := registry.Entries()110+ catalog := make([]GameCatalog, 0, len(entries))111+ for _, entry := range entries {112+ if entry.Print == nil {113+ continue114+ }115+116+ gameModes := make([]ModeCatalog, 0, len(entry.Modes))117+ for _, mode := range entry.Modes {118+ gameModes = append(gameModes, ModeCatalog{119+ GameID: entry.Definition.ID,120+ GameType: entry.Definition.Name,121+ ModeID: mode.Definition.ID,122+ ModeTitle: mode.Definition.Title,123+ Seeded: mode.Seeded != nil,124+ })125+ }126+127+ catalog = append(catalog, GameCatalog{128+ GameID: entry.Definition.ID,129+ GameType: entry.Definition.Name,130+ Modes: gameModes,131+ })132+ }133+ return catalog134+}135+136+func LoadSpecFile(path string) (Spec, error) {137+ data, err := os.ReadFile(path)138+ if err != nil {139+ return Spec{}, fmt.Errorf("read export spec: %w", err)140+ }141+142+ var spec Spec143+ if err := json.Unmarshal(data, &spec); err != nil {144+ return Spec{}, fmt.Errorf("decode export spec: %w", err)145+ }146+147+ return spec, nil148+}149+150+func ValidateSpec(spec Spec) error {151+ _, err := buildRunPlan(spec)152+ return err153+}154+155+func Run(ctx context.Context, spec Spec) (Result, error) {156+ if ctx == nil {157+ ctx = context.Background()158+ }159+160+ builtinprint.Register()161+162+ plan, err := buildRunPlan(spec)163+ if err != nil {164+ return Result{}, err165+ }166+167+ generatedAt := nowFn()168+ records, err := generateRecords(ctx, plan.selections, strings.TrimSpace(plan.spec.Seed), generatedAt)169+ if err != nil {170+ return Result{}, err171+ }172+173+ result := Result{174+ TotalCount: len(records),175+ }176+ if err := ctx.Err(); err != nil {177+ return Result{}, err178+ }179+180+ var stagedJSONL stagedOutput181+ hasJSONL := strings.TrimSpace(plan.spec.JSONLOutputPath) != ""182+ if hasJSONL {183+ stagedJSONL, err = stageJSONL(plan.spec.JSONLOutputPath, records)184+ if err != nil {185+ return Result{}, err186+ }187+ defer stagedJSONL.Cleanup()188+ }189+190+ if err := ctx.Err(); err != nil {191+ return Result{}, err192+ }193+194+ doc, err := pdfexport.ParseJSONLRecords("packexport-generated.jsonl", records)195+ if err != nil {196+ return Result{}, err197+ }198+199+ docs := []pdfexport.PackDocument{doc}200+ puzzles := flattenPuzzles(docs)201+ AnnotatePuzzlesForPrint(puzzles, registry.Definitions())202+203+ shuffleSeed := strings.TrimSpace(plan.spec.Seed)204+ if shuffleSeed == "" {205+ shuffleSeed = generatedAt.Format(time.RFC3339Nano)206+ }207+ ordered := pdfexport.OrderPuzzlesForPrint(puzzles, shuffleSeed)208+209+ cfg, err := BuildRenderConfig(RenderOptions{210+ Title: plan.spec.Title,211+ Header: plan.spec.Header,212+ Advert: plan.spec.Advert,213+ Volume: plan.spec.Volume,214+ SheetLayout: plan.spec.SheetLayout,215+ ShuffleSeed: shuffleSeed,216+ GeneratedAt: generatedAt,217+ }, docs)218+ if err != nil {219+ return Result{}, err220+ }221+222+ stagedPDF, err := stagePDF(plan.spec.PDFOutputPath, docs, ordered, cfg)223+ if err != nil {224+ return Result{}, err225+ }226+ defer stagedPDF.Cleanup()227+228+ if err := ctx.Err(); err != nil {229+ return Result{}, err230+ }231+ if err := stagedPDF.Publish(); err != nil {232+ return Result{}, err233+ }234+ result.PDFOutputPath = plan.spec.PDFOutputPath235+236+ if hasJSONL {237+ if err := stagedJSONL.Publish(); err != nil {238+ return Result{}, err239+ }240+ result.JSONLOutputPath = plan.spec.JSONLOutputPath241+ }242+243+ return result, nil244+}245+246+func zeroCounts() map[puzzle.GameID]map[puzzle.ModeID]int {247+ counts := make(map[puzzle.GameID]map[puzzle.ModeID]int)248+ for _, game := range ExportCatalog() {249+ gameCounts := make(map[puzzle.ModeID]int, len(game.Modes))250+ for _, mode := range game.Modes {251+ gameCounts[mode.ModeID] = 0252+ }253+ counts[game.GameID] = gameCounts254+ }255+ return counts256+}257+258+func setDefaultCount(counts map[puzzle.GameID]map[puzzle.ModeID]int, gameName, modeTitle string, count int) {259+ gameID := puzzle.CanonicalGameID(gameName)260+ modeID := puzzle.CanonicalModeID(modeTitle)261+ gameCounts, ok := counts[gameID]262+ if !ok {263+ return264+ }265+ if _, ok := gameCounts[modeID]; !ok {266+ return267+ }268+ gameCounts[modeID] = count269+}270+271+func buildRunPlan(spec Spec) (runPlan, error) {272+ spec = normalizeSpec(spec)273+274+ if !strings.EqualFold(filepath.Ext(spec.PDFOutputPath), ".pdf") {275+ return runPlan{}, fmt.Errorf("pdf output path must use a .pdf extension")276+ }277+ if strings.TrimSpace(spec.JSONLOutputPath) != "" && !strings.EqualFold(filepath.Ext(spec.JSONLOutputPath), ".jsonl") {278+ return runPlan{}, fmt.Errorf("jsonl output path must use a .jsonl extension")279+ }280+ if spec.Volume < 1 {281+ return runPlan{}, fmt.Errorf("volume must be >= 1")282+ }283+284+ entries := registry.Entries()285+ entryByID := make(map[puzzle.GameID]registry.Entry, len(entries))286+ for _, entry := range entries {287+ entryByID[entry.Definition.ID] = entry288+ }289+290+ selections := []modeSelection{}291+ total := 0292+ for gameID, modes := range spec.Counts {293+ entry, ok := entryByID[gameID]294+ if !ok {295+ for modeID, count := range modes {296+ if count != 0 {297+ return runPlan{}, fmt.Errorf("%s / %s is not a known export target", gameID, modeID)298+ }299+ }300+ continue301+ }302+ if entry.Print == nil {303+ for modeID, count := range modes {304+ if count != 0 {305+ return runPlan{}, fmt.Errorf("%s / %s does not support export", entry.Definition.Name, modeID)306+ }307+ }308+ continue309+ }310+311+ modeByID := make(map[puzzle.ModeID]registry.ModeEntry, len(entry.Modes))312+ for _, mode := range entry.Modes {313+ modeByID[mode.Definition.ID] = mode314+ }315+316+ for modeID, count := range modes {317+ if count < 0 {318+ return runPlan{}, fmt.Errorf("%s / %s count must be >= 0", entry.Definition.Name, modeID)319+ }320+ if count == 0 {321+ continue322+ }323+324+ mode, ok := modeByID[modeID]325+ if !ok {326+ return runPlan{}, fmt.Errorf("%s / %s is not a known export mode", entry.Definition.Name, modeID)327+ }328+ if strings.TrimSpace(spec.Seed) != "" && mode.Seeded == nil {329+ return runPlan{}, fmt.Errorf("%s / %s does not support seeded export", entry.Definition.Name, mode.Definition.Title)330+ }331+332+ total += count333+ selections = append(selections, modeSelection{334+ gameID: entry.Definition.ID,335+ gameType: entry.Definition.Name,336+ modeID: mode.Definition.ID,337+ modeTitle: mode.Definition.Title,338+ count: count,339+ spawner: mode.Spawner,340+ seededSpawner: mode.Seeded,341+ })342+ }343+ }344+345+ if total == 0 {346+ return runPlan{}, fmt.Errorf("total exported puzzle count must be > 0")347+ }348+349+ slices.SortFunc(selections, func(a, b modeSelection) int {350+ if c := strings.Compare(puzzle.NormalizeName(a.gameType), puzzle.NormalizeName(b.gameType)); c != 0 {351+ return c352+ }353+ return strings.Compare(puzzle.NormalizeName(a.modeTitle), puzzle.NormalizeName(b.modeTitle))354+ })355+356+ return runPlan{357+ spec: spec,358+ selections: selections,359+ }, nil360+}361+362+func normalizeSpec(spec Spec) Spec {363+ spec.Title = strings.TrimSpace(spec.Title)364+ spec.Header = strings.TrimSpace(spec.Header)365+ spec.Advert = strings.TrimSpace(spec.Advert)366+ spec.Seed = strings.TrimSpace(spec.Seed)367+ spec.PDFOutputPath = strings.TrimSpace(spec.PDFOutputPath)368+ spec.JSONLOutputPath = strings.TrimSpace(spec.JSONLOutputPath)369+370+ if spec.Volume == 0 {371+ spec.Volume = 1372+ }373+ if spec.SheetLayout == "" {374+ spec.SheetLayout = DefaultSheetLayout375+ }376+ if spec.Advert == "" {377+ spec.Advert = DefaultAdvert378+ }379+ if spec.Counts == nil {380+ spec.Counts = zeroCounts()381+ }382+ return spec383+}384+385+func generateRecords(386+ ctx context.Context,387+ selections []modeSelection,388+ seed string,389+ generatedAt time.Time,390+) ([]pdfexport.JSONLRecord, error) {391+ var rng *randState392+ if seed != "" {393+ rng = newRandState(seed)394+ }395+396+ nameSeed := seed397+ if strings.TrimSpace(nameSeed) == "" {398+ nameSeed = generatedAt.Format(time.RFC3339Nano)399+ }400+ nameRNG := resolve.RNGFromString("export-names:" + nameSeed)401+402+ totalCount := 0403+ for _, selection := range selections {404+ totalCount += selection.count405+ }406+407+ records := make([]pdfexport.JSONLRecord, 0, totalCount)408+ for _, selection := range selections {409+ for i := 0; i < selection.count; i++ {410+ if err := ctx.Err(); err != nil {411+ return nil, err412+ }413+414+ saveData, err := spawnSaveData(ctx, selection, rng)415+ if err != nil {416+ return nil, fmt.Errorf("generate %s / %s puzzle %d: %w", selection.gameType, selection.modeTitle, i+1, err)417+ }418+419+ records = append(records, pdfexport.JSONLRecord{420+ Schema: pdfexport.ExportSchemaV1,421+ Pack: pdfexport.JSONLPackMeta{422+ Generated: generatedAt.Format(time.RFC3339),423+ Version: Version,424+ Category: selection.gameType,425+ ModeSelection: selection.modeTitle,426+ Count: totalCount,427+ Seed: seed,428+ },429+ Puzzle: pdfexport.JSONLPuzzle{430+ Index: len(records) + 1,431+ Name: namegen.GenerateSeeded(nameRNG),432+ Game: selection.gameType,433+ Mode: selection.modeTitle,434+ Save: saveData,435+ },436+ })437+ }438+ }439+440+ return records, nil441+}@@ -2,6 +2,7 @@ package pdfexport
22 33 import (44 "bufio"5+ "bytes"56 "encoding/json"67 "fmt"78 "os"@@ -59,9 +60,30 @@ func ParseJSONLFile(path string) (PackDocument, error) {
5960 defer f.Close()6061 6162 scanner := bufio.NewScanner(f)62- buf := make([]byte, 0, 64*1024)63- scanner.Buffer(buf, 16*1024*1024)63+ scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)64+ return parseJSONLScanner(path, scanner)65+}66+67+func ParseJSONLData(sourceName string, data []byte) (PackDocument, error) {68+ scanner := bufio.NewScanner(bytes.NewReader(data))69+ scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)70+ return parseJSONLScanner(sourceName, scanner)71+}6472 73+func ParseJSONLRecords(sourceName string, records []JSONLRecord) (PackDocument, error) {74+ var b strings.Builder75+ for _, record := range records {76+ line, err := json.Marshal(record)77+ if err != nil {78+ return PackDocument{}, fmt.Errorf("encode jsonl record: %w", err)79+ }80+ b.Write(line)81+ b.WriteByte('\n')82+ }83+ return ParseJSONLData(sourceName, []byte(b.String()))84+}85+86+func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error) {6587 doc := PackDocument{SourcePath: path}6688 doc.Metadata.SourceFileName = filepath.Base(path)6789 puzzles := []Puzzle{}@@ -120,14 +142,11 @@ func ParseJSONLFile(path string) (PackDocument, error) {
120142 }121143 p.PrintPayload = payload122144 123- // Metadata should reflect the first printable record, not merely the124- // first syntactically valid line in the file.125145 if doc.Metadata.GeneratedRaw == "" {126146 doc.Metadata.GeneratedRaw = record.Pack.Generated127147 doc.Metadata.Version = record.Pack.Version128148 doc.Metadata.Count = record.Pack.Count129149 doc.Metadata.Seed = record.Pack.Seed130-131150 doc.Metadata.Category = strings.TrimSpace(p.Category)132151 if doc.Metadata.Category == "" {133152 doc.Metadata.Category = record.Pack.Category@@ -149,11 +168,7 @@ func ParseJSONLFile(path string) (PackDocument, error) {
149168 }150169 if len(puzzles) == 0 {151170 if len(unsupportedCategories) > 0 {152- return PackDocument{}, fmt.Errorf(153- "%s: no printable puzzles found; unsupported categories: %s",154- path,155- strings.Join(unsupportedCategories, ", "),156- )171+ return PackDocument{}, fmt.Errorf("%s: no printable puzzles found; unsupported categories: %s", path, strings.Join(unsupportedCategories, ", "))157172 }158173 return PackDocument{}, fmt.Errorf("%s: no printable puzzles found", path)159174 }@@ -24,6 +24,15 @@ func ErrorNoticeStyle() lipgloss.Style {
2424 Padding(0, 1)2525 }2626 27+// SuccessNoticeStyle returns the style for user-visible success notices.28+func SuccessNoticeStyle() lipgloss.Style {29+ return lipgloss.NewStyle().30+ Border(lipgloss.RoundedBorder()).31+ BorderForeground(theme.Current().SuccessBorder).32+ Foreground(theme.Current().Success).33+ Padding(0, 1)34+}35+2736 // LogoStyle returns the style for the ASCII art brand logo.2837 func LogoStyle() lipgloss.Style {2938 p := theme.Current()