clone

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

chore/regenerate-vhs-gifs
puzzletea / app / export.go
export.go 46.1K
1package app23import (4 "context"5 "fmt"6 "image/color"7 "os"8 "path/filepath"9 "strconv"10 "strings"1112 "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"1819 "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)2627type exportFocus int2829const (30 exportFocusTitle exportFocus = iota31 exportFocusHeader32 exportFocusAdvert33 exportFocusVolume34 exportFocusLayout35 exportFocusSeed36 exportFocusPDFPath37 exportFocusJSONLToggle38 exportFocusJSONLPath39 exportFocusCards40 exportFocusSubmit41)4243var exportSheetLayouts = []string{"half-letter", "duplex-booklet"}4445const exportFooterHint = "tab focus • arrows move buckets • digits set counts • +/- tweak • enter next/export • esc back"4647const (48 exportPanelWidthPadding = 1049 exportPanelHeightPadding = 1050 exportCardGap = 151 exportCardMinWidth = 1552 exportCardMaxColumns = 553 exportCardTargetRows = 354)5556type exportFormValues struct {57 Title string58 Header string59 Advert string60 Volume string61 SheetLayout string62 Seed string63 PDFOutputPath string64 JSONLEnabled bool65 JSONLOutputPath string66}6768type exportGameCard struct {69 GameID puzzle.GameID70 GameType string71 Buckets [3]int72 BucketModes [3][]packexport.ModeCatalog73}7475type exportRect struct {76 X int77 Y int78 W int79 H int80}8182func (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}8586type 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}99100type exportCardRects struct {101 Index int102 Rect exportRect103 Buckets [3]exportRect104}105106type exportCompleteMsg struct {107 jobID int64108 result packexport.Result109 err error110}111112type exportScreen struct {113 width int114 height int115 export exportState116}117118func (s exportScreen) State() viewState { return exportView }119120func (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}128129func (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 }143144 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 }173174 contentWidth, contentHeight := exportContentBounds(s.width, s.height)175 if handled := s.export.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {176 return s, nil, nil177 }178 }179180 cmd := s.export.updateFocusedInput(msg)181 return s, cmd, nil182}183184func (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}195196func (s exportScreen) Apply(m model) model {197 m.state = exportView198 m.export = s.export199 return m200}201202type exportRunningScreen struct {203 width int204 height int205 spinner spinner.Model206}207208func (s exportRunningScreen) State() viewState { return exportRunningView }209210func (s exportRunningScreen) Resize(width, height int) screenModel {211 s.width = width212 s.height = height213 return s214}215216func (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}221222func (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}227228func (s exportRunningScreen) Apply(m model) model {229 m.state = exportRunningView230 m.spinner = s.spinner231 return m232}233234func (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}245246func (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 }251252 m.cfg.Export = cfg253 if err := m.cfg.Save(m.configPath); err != nil {254 m = m.setErrorf("Export settings save failed: %v", err)255 }256257 cmd := m.startExport(spec)258 return m, cmd259}260261func (m model) handleExportComplete(msg exportCompleteMsg) (tea.Model, tea.Cmd) {262 if msg.jobID != m.export.jobID {263 return m, nil264 }265266 m.export.running = false267 if m.export.cancel != nil {268 m.export.cancel()269 m.export.cancel = nil270 }271272 m.state = exportView273274 if msg.err != nil {275 return m.setErrorf("Export failed: %v", msg.err), nil276 }277278 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}284285func (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}295296func (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}307308func 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}314315func 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}326327func exportContentBounds(width, height int) (int, int) {328 return max(width-exportPanelWidthPadding, 24), max(height-exportPanelHeightPadding, 8)329}330331func (m model) initialExportSpec() packexport.Spec {332 cwd, err := os.Getwd()333 if err != nil {334 cwd = "."335 }336337 base := packexport.DefaultSpec(cwd)338 if !hasSavedExportConfig(m.cfg.Export) {339 return base340 }341342 return applySavedExportConfig(base, m.cfg.Export)343}344345func 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}357358func 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}393394func 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}411412func 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}419420func 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}439440func bucketIndexForMode(index, total int) int {441 if total <= 0 {442 return 0443 }444 return index * 3 / total445}446447func (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}458459func 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}468469func 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}481482func 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}494495func 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}513514func (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)522523 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}533534func (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}551552func (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}568569func (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()577578 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}597598func (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}625626func (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}694695func (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}706707func (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}721722func (s *exportState) backspaceSelectedBucket() {723 if len(s.cards) == 0 {724 return725 }726 card := &s.cards[s.cardIndex]727 card.Buckets[s.bucketIndex] /= 10728}729730func (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}744745func (s *exportState) toggleJSONL() {746 s.values.JSONLEnabled = !s.values.JSONLEnabled747 if !s.values.JSONLEnabled && s.focus == exportFocusJSONLPath {748 s.focus = exportFocusCards749 }750}751752func (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}762763func (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}777778func (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}786787func (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}803804func (s *exportState) visibleCardRows(width, height int) int {805 if len(s.cards) == 0 {806 return 1807 }808809 cardWidth := exportCardWidth(width, s.cardColumns(width))810 cardHeight := lipgloss.Height(s.renderCard(0, cardWidth))811 if cardHeight <= 0 {812 cardHeight = 4813 }814815 fixedHeight := lipgloss.Height(s.renderSettings(width)) + lipgloss.Height(s.renderSummary(width))816 available := max(height-fixedHeight-1, cardHeight)817818 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}829830func 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}840841func (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}847848func (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}889890func (s *exportState) settingsRects(width int) exportSettingsRects {891 if width < 72 {892 return s.stackedSettingsRects(width)893 }894895 y := 0896 inlineHeight := s.inlineFieldHeight(width)897 headerHeight := s.headerFieldHeight(width)898899 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 += headerHeight908909 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 += inlineHeight918919 row1.PDFPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}920 y += inlineHeight921922 if s.values.JSONLEnabled {923 row1.HasJSONL = true924 row1.JSONLPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}925 y += inlineHeight926 }927928 row1.TotalHeight = y929 return row1930}931932func (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 += headerHeight944945 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 }963964 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}974975func (s *exportState) inlineFieldHeight(width int) int {976 input := s.titleInput977 return lipgloss.Height(s.renderInlineInputField("Title", &input, false, max(width, 14)))978}979980func (s *exportState) headerFieldHeight(width int) int {981 input := s.headerInput982 return lipgloss.Height(s.renderTextareaField("Header", &input, false, max(width, 24)))983}984985func (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}10111012func (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}10261027func (s *exportState) renderSettings(width int) string {1028 if width < 72 {1029 return s.renderStackedSettings(width)1030 }10311032 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)10401041 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 )10541055 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 }10641065 return lipgloss.JoinVertical(lipgloss.Left, rows...)1066}10671068func (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 }10741075 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 }10951096 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}11021103func (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 }11081109 innerWidth := max(width-4, 1)1110 input.SetWidth(innerWidth)1111 input.SetHeight(3)11121113 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}11251126func (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 }11311132 innerWidth := max(width-4, 1)1133 labelWidth := lipgloss.Width(label) + 11134 input.SetWidth(max(innerWidth-labelWidth, 1))11351136 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}11501151func (s *exportState) renderInlineChoiceField(label, value string, focused bool, width int) string {1152 border := theme.Current().Border1153 if focused {1154 border = theme.Current().Accent1155 }11561157 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 }11631164 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}11781179func 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}11901191func 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}12031204func layoutLabel(layout string) string {1205 switch layout {1206 case "duplex-booklet":1207 return "Booklet"1208 default:1209 return "Half Letter"1210 }1211}12121213func onOffLabel(enabled bool) string {1214 if enabled {1215 return "On"1216 }1217 return "Off"1218}12191220func (s *exportState) renderSummary(width int) string {1221 total := 01222 for _, card := range s.cards {1223 for _, count := range card.Buckets {1224 total += count1225 }1226 }12271228 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 }12371238 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}12451246var bucketNames = [3]string{"Easy", "Medium", "Hard"}12471248func (s *exportState) renderCards(width, height int) string {1249 if len(s.cards) == 0 {1250 return ui.DimItemStyle().Render("No exportable games found.")1251 }12521253 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)12571258 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 }12671268 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 }12731274 content := strings.Join(rows, "\n")1275 if footer != "" {1276 content = lipgloss.JoinVertical(lipgloss.Left, content, footer)1277 }1278 return content1279}12801281func (s *exportState) renderCard(index, width int) string {1282 card := s.cards[index]1283 selected := index == s.cardIndex12841285 border := theme.Current().Border1286 if selected {1287 border = theme.Current().Accent1288 }12891290 titleStyle := ui.PanelTitle()1291 if selected {1292 titleStyle = ui.SelectedItemStyle()1293 }12941295 bucketParts := make([]string, 0, 3)1296 for bucketIndex, name := range bucketNames {1297 bucketParts = append(bucketParts, s.renderDifficultyPair(card, selected, bucketIndex, name))1298 }12991300 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 )13091310 body := lipgloss.JoinVertical(1311 lipgloss.Left,1312 titleLine,1313 lipgloss.JoinHorizontal(lipgloss.Top, bucketParts[0], " ", bucketParts[1], " ", bucketParts[2]),1314 )13151316 return lipgloss.NewStyle().1317 Border(lipgloss.RoundedBorder()).1318 BorderForeground(border).1319 Padding(0, 1).1320 Width(width).1321 Render(body)1322}13231324func (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 }13351336 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}13421343func (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 }13521353 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}13661367func 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)13731374 frame := ui.PanelFrame()1375 contentX = panelX + frame.GetHorizontalFrameSize()/21376 contentY = panelY + 1 + 2 + 21377 return panelX, panelY, contentX, contentY1378}13791380func (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}14081409func (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}14281429func (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}14581459func (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}14661467func (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}14801481func (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}14981499func (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}15061507type 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}15231524func 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}15361537func exportCardWidth(width, cols int) int {1538 if cols <= 0 {1539 return width1540 }1541 return max((width-(cols-1)*exportCardGap)/cols, exportCardMinWidth)1542}15431544func 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}15571558func 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}15711572func (s exportState) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {1573 sync := s1574 sync.syncValuesFromInputs()15751576 spec := sync.values.toExactSpec()1577 spec.Counts = expandCardCounts(sync.cards)1578 if !sync.values.JSONLEnabled {1579 spec.JSONLOutputPath = ""1580 }15811582 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}15961597func (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}16061607func (v exportFormValues) toExactSpec() packexport.Spec {1608 volume, err := strconv.Atoi(strings.TrimSpace(v.Volume))1609 if err != nil {1610 volume = -11611 }16121613 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}16281629func 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}