clone

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

export to pdf from tui

Dami committed 1a91557
commit
1a915576c482edc8af57a81298887be8f463d2cc
parent
ddec2b5b57ce1f855b77c8f6e96005bbbf44d447
author
Dami <dami.etoile@gmail.com>
30 files changed, +3549 -176
Aapp/export.go1652++++++++++++++++++++++++++++++
Aapp/export_test.go539++++++++++
Mapp/handle_theme.go3+-
Mapp/model.go59+-
Mapp/notice.go17+-
Mapp/screen_core.go20+
Mapp/screen_menu.go2+
Mapp/screens_test.go56+-
Mapp/update.go18+
Mapp/view.go2+-
Mcmd/config_test.go2+
Mcmd/continue.go2+-
Acmd/export.go48+
Mcmd/export_pdf.go116+--
Acmd/export_test.go61+
Mcmd/new.go6+-
Mcmd/new_export.go31+-
Mcmd/root.go17+-
Mcmd/session.go3+-
Mconfig/config.go21+-
Mconfig/config_test.go41+
Mgo.mod9+
Mgo.sum19+
Mjustfile2+-
Apackexport/packexport_test.go176+++
Apackexport/render.go123++
Apackexport/runtime.go195++++
Apackexport/spec.go441++++++++
Mpdfexport/jsonl.go35+-
Mui/style.go9+
A app/export.go +1652 -0
@@ -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+}
A app/export_test.go +539 -0
@@ -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+}
M app/handle_theme.go +1 -2
M app/model.go +44 -15
@@ -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,
M app/notice.go +16 -1
M app/screen_core.go +20 -0
M app/screen_menu.go +2 -0
M app/screens_test.go +49 -7
@@ -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
M app/update.go +18 -0
@@ -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 }
M app/view.go +1 -1
M cmd/config_test.go +2 -0
M cmd/continue.go +1 -1
A cmd/export.go +48 -0
M cmd/export_pdf.go +16 -100
@@ -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 }
A cmd/export_test.go +61 -0
M cmd/new.go +3 -3
M cmd/new_export.go +6 -25
@@ -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 }
M cmd/root.go +10 -7
M cmd/session.go +2 -1
M config/config.go +19 -2
M config/config_test.go +41 -0
M go.mod +9 -0
@@ -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
M go.sum +19 -0
@@ -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=
M justfile +1 -1
A packexport/packexport_test.go +176 -0
@@ -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+}
A packexport/render.go +123 -0
@@ -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+}
A packexport/runtime.go +195 -0
@@ -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+}
A packexport/spec.go +441 -0
@@ -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+}
M pdfexport/jsonl.go +25 -10
@@ -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 }
M ui/style.go +9 -0