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 "github.com/FelineStateMachine/puzzletea/export/pack"14 puzzlegame "github.com/FelineStateMachine/puzzletea/game"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 exportModel struct {28 focus exportFocus29 values exportFormValues30 titleInput textinput.Model31 headerInput textarea.Model32 advertInput textinput.Model33 volumeInput textinput.Model34 seedInput textinput.Model35 pdfPathInput textinput.Model36 jsonlPathInput textinput.Model37 cards []exportGameCard38 cardIndex int39 bucketIndex int40 cardRowOffset int41 width int42 height int43}4445type exportFocus int4647const (48 exportFocusTitle exportFocus = iota49 exportFocusHeader50 exportFocusAdvert51 exportFocusVolume52 exportFocusLayout53 exportFocusSeed54 exportFocusPDFPath55 exportFocusJSONLToggle56 exportFocusJSONLPath57 exportFocusCards58 exportFocusSubmit59)6061var exportSheetLayouts = []string{"half-letter", "duplex-booklet"}6263const exportFooterHint = "tab focus • arrows move buckets • digits set counts • +/- tweak • enter next/export • esc back"6465const (66 exportPanelWidthPadding = 1067 exportPanelHeightPadding = 1068 exportCardGap = 169 exportCardMinWidth = 1570 exportCardMaxColumns = 571 exportCardTargetRows = 372)7374type exportFormValues struct {75 Title string76 Header string77 Advert string78 Volume string79 SheetLayout string80 Seed string81 PDFOutputPath string82 JSONLEnabled bool83 JSONLOutputPath string84}8586type exportGameCard struct {87 GameID puzzle.GameID88 GameType string89 Buckets [3]int90 BucketModes [3][]packexport.ModeCatalog91}9293type exportRect struct {94 X int95 Y int96 W int97 H int98}99100func (r exportRect) contains(x, y int) bool {101 return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H102}103104type exportSettingsRects struct {105 Title exportRect106 Advert exportRect107 Header exportRect108 Volume exportRect109 Layout exportRect110 Seed exportRect111 JSONL exportRect112 PDFPath exportRect113 JSONLPath exportRect114 HasJSONL bool115 TotalHeight int116}117118type exportCardRects struct {119 Index int120 Rect exportRect121 Buckets [3]exportRect122}123124type exportCompleteMsg struct {125 jobID int64126 result packexport.Result127 err error128}129130// exportSubmitCmd and exportBackCmd are sentinel commands emitted by131// exportModel.Update to communicate submit/cancel to the root model.132var (133 exportSubmitCmd = func() tea.Msg { return exportSubmitAction{} }134 exportBackCmd = func() tea.Msg { return backAction{target: mainMenuView} }135)136137// Update handles all export form input and returns sentinel commands for138// submit (exportSubmitCmd) and cancel (exportBackCmd).139func (s *exportModel) Update(msg tea.Msg) (exportModel, tea.Cmd) {140 if clickMsg, ok := msg.(tea.MouseClickMsg); ok {141 contentWidth, contentHeight := exportContentBounds(s.width, s.height)142 if cmd, handled := s.handleMouseClick(clickMsg, s.width, s.height, contentWidth, contentHeight); handled {143 return *s, cmd144 }145 return *s, nil146 }147 if wheelMsg, ok := msg.(tea.MouseWheelMsg); ok {148 contentWidth, contentHeight := exportContentBounds(s.width, s.height)149 if handled := s.handleMouseWheel(wheelMsg, s.width, s.height, contentWidth, contentHeight); handled {150 return *s, nil151 }152 }153154 keyMsg, ok := msg.(tea.KeyPressMsg)155 if ok {156 switch {157 case key.Matches(keyMsg, rootKeys.Escape):158 return *s, exportBackCmd159 case keyMsg.String() == "tab":160 cmd := s.moveFocus(1)161 return *s, cmd162 case keyMsg.String() == "shift+tab":163 cmd := s.moveFocus(-1)164 return *s, cmd165 case key.Matches(keyMsg, rootKeys.Enter):166 if s.focus == exportFocusSubmit {167 return *s, exportSubmitCmd168 }169 if s.focus == exportFocusHeader {170 break171 }172 if s.focus == exportFocusLayout {173 s.cycleLayout(1)174 return *s, nil175 }176 if s.focus == exportFocusJSONLToggle {177 s.toggleJSONL()178 return *s, nil179 }180 cmd := s.moveFocus(1)181 return *s, cmd182 }183184 contentWidth, contentHeight := exportContentBounds(s.width, s.height)185 if handled := s.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {186 return *s, nil187 }188 }189190 cmd := s.updateFocusedInput(msg)191 return *s, cmd192}193194type exportScreen struct {195 width int196 height int197 export exportModel198 // job lifecycle — kept here so exportModel is pure form state199 running bool200 jobID int64201 cancel context.CancelFunc202}203204func (s exportScreen) Resize(width, height int) screenModel {205 s.width = width206 s.height = height207 s.export.width = width208 s.export.height = height209 contentWidth, contentHeight := exportContentBounds(width, height)210 s.export.resize(contentWidth)211 s.export.ensureCardSelectionVisible(contentWidth, contentHeight)212 return s213}214215func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {216 next, cmd := s.export.Update(msg)217 s.export = next218 return s, cmd, nil219}220221func (s exportScreen) View(notice noticeState) string {222 contentWidth, contentHeight := exportContentBounds(s.width, s.height)223 return renderPanelView(224 s.width,225 s.height,226 notice,227 "Export",228 s.export.view(contentWidth, contentHeight),229 exportFooterHint,230 )231}232233type exportRunningScreen struct {234 width int235 height int236 spinner spinner.Model237}238239func (s exportRunningScreen) Resize(width, height int) screenModel {240 s.width = width241 s.height = height242 return s243}244245func (s exportRunningScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {246 var cmd tea.Cmd247 s.spinner, cmd = s.spinner.Update(msg)248 return s, cmd, nil249}250251func (s exportRunningScreen) View(notice noticeState) string {252 content := s.spinner.View() + " Exporting puzzle pack..."253 box := ui.GeneratingFrame().Render(appendNoticeContent(s.width, notice, content))254 return ui.CenterView(s.width, s.height, box)255}256257func (m model) activeExportScreen() (exportScreen, bool) {258 s, ok := m.screens[exportView].(exportScreen)259 return s, ok260}261262func (m model) handleExportEnter() (model, tea.Cmd) {263 if m.screens == nil {264 m.screens = make(map[viewState]screenModel)265 }266 if _, exists := m.screens[exportView]; !exists {267 spec := m.initialExportSpec()268 es := exportScreen{269 width: m.width,270 height: m.height,271 export: buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), m.width),272 }273 m.screens[exportView] = es.Resize(m.width, m.height)274 } else {275 m.screens[exportView] = m.screens[exportView].Resize(m.width, m.height)276 }277 m.state = exportView278 m = m.clearNotice()279 return m, nil280}281282func (m model) handleExportSubmit() (model, tea.Cmd) {283 es, ok := m.activeExportScreen()284 if !ok {285 return m, nil286 }287 spec, cfg := es.export.toSpecAndConfig()288 if err := packexport.ValidateSpec(spec); err != nil {289 return m.setErrorf("Could not start export: %v", err), nil290 }291292 m.cfg.Export = cfg293 if err := m.cfg.Save(m.configPath); err != nil {294 m = m.setErrorf("Export settings save failed: %v", err)295 }296297 cmd := m.startExport(spec)298 return m, cmd299}300301func (m model) handleExportComplete(msg exportCompleteMsg) (model, tea.Cmd) {302 es, ok := m.activeExportScreen()303 if !ok || msg.jobID != es.jobID {304 return m, nil305 }306307 if es.cancel != nil {308 es.cancel()309 es.cancel = nil310 }311 es.running = false312 m.screens[exportView] = es313 m.state = exportView314315 if msg.err != nil {316 return m.setErrorf("Export failed: %v", msg.err), nil317 }318319 message := fmt.Sprintf("Exported %d puzzles to %s", msg.result.TotalCount, msg.result.PDFOutputPath)320 if msg.result.JSONLOutputPath != "" {321 message += fmt.Sprintf(" and %s", msg.result.JSONLOutputPath)322 }323 return m.setSuccessf("%s", message), nil324}325326// handleExportRunningKey handles global keys while an export is running.327// All key messages are consumed: escape cancels the export and returns to328// the export form, quit exits the app, and all other keys are swallowed.329func (m model) handleExportRunningKey(keyMsg tea.KeyPressMsg) (model, tea.Cmd, bool) {330 switch {331 case key.Matches(keyMsg, rootKeys.Escape):332 m.cancelActiveExport()333 m.state = exportView334 return m.resizeActiveScreen(), nil, true335 case key.Matches(keyMsg, rootKeys.Quit):336 return m, tea.Quit, true337 }338 return m, nil, true339}340341func (m *model) cancelActiveExport() {342 es, ok := m.screens[exportView].(exportScreen)343 if !ok {344 return345 }346 if es.cancel != nil {347 es.cancel()348 es.cancel = nil349 }350 if es.running {351 es.jobID++352 }353 es.running = false354 m.screens[exportView] = es355}356357func (m *model) startExport(spec packexport.Spec) tea.Cmd {358 m.cancelActiveExport()359 es, _ := m.screens[exportView].(exportScreen)360 es.jobID++361 jobID := es.jobID362 ctx, cancel := context.WithCancel(context.Background())363 es.cancel = cancel364 es.running = true365 m.screens[exportView] = es366 m.state = exportRunningView367 *m = m.clearNotice()368 *m = m.initScreen(exportRunningView)369 return tea.Batch(m.spinner.Tick, exportCmd(ctx, jobID, spec))370}371372func exportCmd(ctx context.Context, jobID int64, spec packexport.Spec) tea.Cmd {373 return func() tea.Msg {374 result, err := packexport.Run(ctx, spec)375 return exportCompleteMsg{jobID: jobID, result: result, err: err}376 }377}378379func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportModel {380 contentWidth, _ := exportContentBounds(width, 0)381 state := exportModel{382 focus: exportFocusCards,383 values: values,384 cards: cards,385 width: width,386 }387 state.initInputs(contentWidth)388 return state389}390391func exportContentBounds(width, height int) (int, int) {392 return max(width-exportPanelWidthPadding, 24), max(height-exportPanelHeightPadding, 8)393}394395func (m model) initialExportSpec() packexport.Spec {396 cwd, err := os.Getwd()397 if err != nil {398 cwd = "."399 }400401 base := packexport.DefaultSpec(cwd)402 if !hasSavedExportConfig(m.cfg.Export) {403 return base404 }405406 return applySavedExportConfig(base, m.cfg.Export)407}408409func hasSavedExportConfig(cfg config.ExportConfig) bool {410 return cfg.Title != "" ||411 cfg.Header != "" ||412 cfg.Advert != "" ||413 cfg.Volume != 0 ||414 cfg.SheetLayout != "" ||415 cfg.Seed != "" ||416 cfg.PDFOutputPath != "" ||417 cfg.JSONLOutputPath != "" ||418 cfg.JSONLEnabled ||419 len(cfg.Counts) > 0420}421422func applySavedExportConfig(base packexport.Spec, cfg config.ExportConfig) packexport.Spec {423 if cfg.Title != "" {424 base.Title = cfg.Title425 }426 if cfg.Header != "" {427 base.Header = cfg.Header428 }429 if cfg.Advert != "" {430 base.Advert = cfg.Advert431 }432 if cfg.Volume > 0 {433 base.Volume = cfg.Volume434 }435 if cfg.SheetLayout != "" {436 base.SheetLayout = cfg.SheetLayout437 }438 base.Seed = cfg.Seed439 if cfg.PDFOutputPath != "" {440 base.PDFOutputPath = cfg.PDFOutputPath441 }442 if cfg.JSONLEnabled {443 base.JSONLOutputPath = cfg.JSONLOutputPath444 } else {445 base.JSONLOutputPath = ""446 }447 for gameID, modes := range cfg.Counts {448 if _, ok := base.Counts[gameID]; !ok {449 base.Counts[gameID] = map[puzzle.ModeID]int{}450 }451 for modeID, count := range modes {452 base.Counts[gameID][modeID] = count453 }454 }455 return base456}457458func exportValuesFromSpec(spec packexport.Spec) exportFormValues {459 values := exportFormValues{460 Title: spec.Title,461 Header: spec.Header,462 Advert: spec.Advert,463 Volume: strconv.Itoa(spec.Volume),464 SheetLayout: spec.SheetLayout,465 Seed: spec.Seed,466 PDFOutputPath: spec.PDFOutputPath,467 JSONLEnabled: strings.TrimSpace(spec.JSONLOutputPath) != "",468 JSONLOutputPath: defaultJSONLPath(spec),469 }470 if strings.TrimSpace(spec.JSONLOutputPath) != "" {471 values.JSONLOutputPath = spec.JSONLOutputPath472 }473 return values474}475476func defaultJSONLPath(spec packexport.Spec) string {477 dir := filepath.Dir(spec.PDFOutputPath)478 if dir == "." || dir == "" {479 dir = "out"480 }481 return filepath.Join(dir, "puzzletea-export.jsonl")482}483484func buildExportCardsFromSpec(spec packexport.Spec) []exportGameCard {485 catalog := packexport.ExportCatalog()486 cards := make([]exportGameCard, 0, len(catalog))487 for _, game := range catalog {488 card := exportGameCard{489 GameID: game.GameID,490 GameType: game.GameType,491 }492 for i, mode := range game.Modes {493 bucket := bucketIndexForMode(i, len(game.Modes))494 card.BucketModes[bucket] = append(card.BucketModes[bucket], mode)495 if modes, ok := spec.Counts[mode.GameID]; ok {496 card.Buckets[bucket] += modes[mode.ModeID]497 }498 }499 cards = append(cards, card)500 }501 return cards502}503504func bucketIndexForMode(index, total int) int {505 if total <= 0 {506 return 0507 }508 return index * 3 / total509}510511func (s *exportModel) initInputs(width int) {512 s.titleInput = newExportInput(s.values.Title, "Sampler title")513 s.headerInput = newExportTextarea(s.values.Header, "Short intro")514 s.advertInput = newExportInput(s.values.Advert, "Footer copy")515 s.volumeInput = newExportInput(s.values.Volume, "1")516 s.seedInput = newExportInput(s.values.Seed, "optional seed")517 s.pdfPathInput = newExportInput(s.values.PDFOutputPath, "output pdf")518 s.jsonlPathInput = newExportInput(s.values.JSONLOutputPath, "optional jsonl")519 s.resize(width)520 s.applyFocus()521}522523func newExportInput(value, placeholder string) textinput.Model {524 input := textinput.New()525 input.Prompt = ""526 input.SetValue(value)527 input.Placeholder = placeholder528 input.CharLimit = 512529 styleExportInput(&input)530 return input531}532533func newExportTextarea(value, placeholder string) textarea.Model {534 input := textarea.New()535 input.Prompt = ""536 input.SetValue(value)537 input.Placeholder = placeholder538 input.CharLimit = 512539 input.ShowLineNumbers = false540 input.EndOfBufferCharacter = ' '541 input.SetHeight(3)542 styleExportTextarea(&input)543 return input544}545546func styleExportInput(input *textinput.Model) {547 palette := theme.Current()548 styles := input.Styles()549 styles.Focused.Text = lipgloss.NewStyle().Foreground(palette.FG)550 styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)551 styles.Focused.Prompt = lipgloss.NewStyle().Foreground(palette.Accent)552 styles.Blurred.Text = lipgloss.NewStyle().Foreground(palette.FG)553 styles.Blurred.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)554 styles.Blurred.Prompt = lipgloss.NewStyle().Foreground(palette.TextDim)555 styles.Cursor.Color = palette.Accent556 input.SetStyles(styles)557}558559func styleExportTextarea(input *textarea.Model) {560 palette := theme.Current()561 styles := input.Styles()562 styles.Focused.Base = lipgloss.NewStyle()563 styles.Focused.Text = lipgloss.NewStyle().Foreground(palette.FG)564 styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)565 styles.Focused.Prompt = lipgloss.NewStyle().Foreground(palette.Accent)566 styles.Focused.CursorLine = lipgloss.NewStyle()567 styles.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(palette.TextDim)568 styles.Blurred.Base = lipgloss.NewStyle()569 styles.Blurred.Text = lipgloss.NewStyle().Foreground(palette.FG)570 styles.Blurred.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)571 styles.Blurred.Prompt = lipgloss.NewStyle().Foreground(palette.TextDim)572 styles.Blurred.CursorLine = lipgloss.NewStyle()573 styles.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(palette.TextDim)574 styles.Cursor.Color = palette.Accent575 input.SetStyles(styles)576}577578func (s *exportModel) resize(width int) {579 styleExportInput(&s.titleInput)580 styleExportTextarea(&s.headerInput)581 styleExportInput(&s.advertInput)582 styleExportInput(&s.volumeInput)583 styleExportInput(&s.seedInput)584 styleExportInput(&s.pdfPathInput)585 styleExportInput(&s.jsonlPathInput)586587 fieldW := max(18, min((width-22)/3, 38))588 s.titleInput.SetWidth(fieldW)589 s.headerInput.SetWidth(fieldW)590 s.headerInput.SetHeight(3)591 s.advertInput.SetWidth(fieldW)592 s.volumeInput.SetWidth(6)593 s.seedInput.SetWidth(max(18, min(fieldW, 26)))594 s.pdfPathInput.SetWidth(max(26, min(width-20, 72)))595 s.jsonlPathInput.SetWidth(max(26, min(width-20, 72)))596}597598func (s *exportModel) visibleFocuses() []exportFocus {599 focuses := []exportFocus{600 exportFocusTitle,601 exportFocusAdvert,602 exportFocusHeader,603 exportFocusVolume,604 exportFocusLayout,605 exportFocusSeed,606 exportFocusPDFPath,607 exportFocusJSONLToggle,608 }609 if s.values.JSONLEnabled {610 focuses = append(focuses, exportFocusJSONLPath)611 }612 focuses = append(focuses, exportFocusCards, exportFocusSubmit)613 return focuses614}615616func (s *exportModel) moveFocus(delta int) tea.Cmd {617 focuses := s.visibleFocuses()618 current := 0619 for i, focus := range focuses {620 if focus == s.focus {621 current = i622 break623 }624 }625 next := (current + delta) % len(focuses)626 if next < 0 {627 next += len(focuses)628 }629 s.focus = focuses[next]630 switch s.focus {631 case exportFocusPDFPath:632 s.pdfPathInput.SetCursor(runeIdxBeforeExt(s.values.PDFOutputPath))633 case exportFocusJSONLPath:634 s.jsonlPathInput.SetCursor(runeIdxBeforeExt(s.values.JSONLOutputPath))635 }636 return s.applyFocus()637}638639func (s *exportModel) applyFocus() tea.Cmd {640 s.titleInput.Blur()641 s.headerInput.Blur()642 s.advertInput.Blur()643 s.volumeInput.Blur()644 s.seedInput.Blur()645 s.pdfPathInput.Blur()646 s.jsonlPathInput.Blur()647648 switch s.focus {649 case exportFocusTitle:650 return s.titleInput.Focus()651 case exportFocusHeader:652 return s.headerInput.Focus()653 case exportFocusAdvert:654 return s.advertInput.Focus()655 case exportFocusVolume:656 return s.volumeInput.Focus()657 case exportFocusSeed:658 return s.seedInput.Focus()659 case exportFocusPDFPath:660 return s.pdfPathInput.Focus()661 case exportFocusJSONLPath:662 return s.jsonlPathInput.Focus()663 default:664 return nil665 }666}667668func (s *exportModel) updateFocusedInput(msg tea.Msg) tea.Cmd {669 var cmd tea.Cmd670 switch s.focus {671 case exportFocusTitle:672 s.titleInput, cmd = s.titleInput.Update(msg)673 s.values.Title = s.titleInput.Value()674 case exportFocusHeader:675 s.headerInput, cmd = s.headerInput.Update(msg)676 s.values.Header = s.headerInput.Value()677 case exportFocusAdvert:678 s.advertInput, cmd = s.advertInput.Update(msg)679 s.values.Advert = s.advertInput.Value()680 case exportFocusVolume:681 s.volumeInput, cmd = s.volumeInput.Update(msg)682 s.values.Volume = s.volumeInput.Value()683 case exportFocusSeed:684 s.seedInput, cmd = s.seedInput.Update(msg)685 s.values.Seed = s.seedInput.Value()686 case exportFocusPDFPath:687 s.pdfPathInput, cmd = s.pdfPathInput.Update(msg)688 s.values.PDFOutputPath = s.pdfPathInput.Value()689 case exportFocusJSONLPath:690 s.jsonlPathInput, cmd = s.jsonlPathInput.Update(msg)691 s.values.JSONLOutputPath = s.jsonlPathInput.Value()692 }693 return cmd694}695696func (s *exportModel) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {697 switch s.focus {698 case exportFocusLayout:699 switch msg.String() {700 case "left", "h":701 s.cycleLayout(-1)702 return true703 case "right", "l":704 s.cycleLayout(1)705 return true706 }707 case exportFocusJSONLToggle:708 switch msg.String() {709 case "left", "right", "h", "l", " ":710 s.toggleJSONL()711 return true712 }713 case exportFocusCards:714 switch msg.String() {715 case "left":716 s.moveBucketSelection(-1, width, height)717 return true718 case "right":719 s.moveBucketSelection(1, width, height)720 return true721 case "h":722 s.moveBucketSelection(-1, width, height)723 return true724 case "l":725 s.moveBucketSelection(1, width, height)726 return true727 case "up", "k":728 cols := s.cardColumns(width)729 if s.cardIndex-cols >= 0 {730 s.cardIndex -= cols731 }732 s.ensureCardSelectionVisible(width, height)733 return true734 case "down", "j":735 cols := s.cardColumns(width)736 if s.cardIndex+cols < len(s.cards) {737 s.cardIndex += cols738 }739 s.ensureCardSelectionVisible(width, height)740 return true741 case "+", "=":742 s.adjustSelectedBucket(1)743 return true744 case "-":745 s.adjustSelectedBucket(-1)746 return true747 case "backspace", "delete":748 s.backspaceSelectedBucket()749 return true750 case "pgup":751 s.cardRowOffset = max(s.cardRowOffset-1, 0)752 return true753 case "pgdown":754 s.cardRowOffset++755 s.ensureCardSelectionVisible(width, height)756 return true757 case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":758 s.appendSelectedBucketDigit(msg.String())759 return true760 }761 }762 return false763}764765func (s *exportModel) moveBucketSelection(delta, width, height int) {766 if len(s.cards) == 0 || delta == 0 {767 return768 }769 position := s.cardIndex*len(bucketNames) + s.bucketIndex + delta770 maxPosition := len(s.cards)*len(bucketNames) - 1771 position = max(0, min(position, maxPosition))772 s.cardIndex = position / len(bucketNames)773 s.bucketIndex = position % len(bucketNames)774 s.ensureCardSelectionVisible(width, height)775}776777func (s *exportModel) appendSelectedBucketDigit(raw string) {778 if len(s.cards) == 0 {779 return780 }781 digit, err := strconv.Atoi(raw)782 if err != nil {783 return784 }785 card := &s.cards[s.cardIndex]786 if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {787 return788 }789 card.Buckets[s.bucketIndex] = card.Buckets[s.bucketIndex]*10 + digit790}791792func (s *exportModel) backspaceSelectedBucket() {793 if len(s.cards) == 0 {794 return795 }796 card := &s.cards[s.cardIndex]797 card.Buckets[s.bucketIndex] /= 10798}799800func (s *exportModel) cycleLayout(delta int) {801 index := 0802 for i, layout := range exportSheetLayouts {803 if layout == s.values.SheetLayout {804 index = i805 break806 }807 }808 index = (index + delta) % len(exportSheetLayouts)809 if index < 0 {810 index += len(exportSheetLayouts)811 }812 s.values.SheetLayout = exportSheetLayouts[index]813}814815func (s *exportModel) toggleJSONL() {816 s.values.JSONLEnabled = !s.values.JSONLEnabled817 if !s.values.JSONLEnabled && s.focus == exportFocusJSONLPath {818 s.focus = exportFocusCards819 }820}821822func (s *exportModel) adjustSelectedBucket(delta int) {823 if len(s.cards) == 0 {824 return825 }826 card := &s.cards[s.cardIndex]827 if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {828 return829 }830 card.Buckets[s.bucketIndex] = max(card.Buckets[s.bucketIndex]+delta, 0)831}832833func (c exportGameCard) bucketSeeded(index int) bool {834 if index < 0 || index >= len(c.BucketModes) {835 return true836 }837 if len(c.BucketModes[index]) == 0 {838 return true839 }840 for _, mode := range c.BucketModes[index] {841 if !mode.Seeded {842 return false843 }844 }845 return true846}847848func (s *exportModel) cardColumns(width int) int {849 for cols := exportCardMaxColumns; cols >= 1; cols-- {850 if exportCardWidth(width, cols) >= exportCardMinWidth {851 return cols852 }853 }854 return 1855}856857func (s *exportModel) ensureCardSelectionVisible(width, height int) {858 cols := s.cardColumns(width)859 if cols <= 0 {860 return861 }862 selectedRow := s.cardIndex / cols863 visibleRows := s.visibleCardRows(width, height)864 if selectedRow < s.cardRowOffset {865 s.cardRowOffset = selectedRow866 }867 if selectedRow >= s.cardRowOffset+visibleRows {868 s.cardRowOffset = selectedRow - visibleRows + 1869 }870 maxRows := max((len(s.cards)+cols-1)/cols, 1)871 s.cardRowOffset = min(max(s.cardRowOffset, 0), maxRows-1)872}873874func (s *exportModel) visibleCardRows(width, height int) int {875 if len(s.cards) == 0 {876 return 1877 }878879 cardWidth := exportCardWidth(width, s.cardColumns(width))880 cardHeight := lipgloss.Height(s.renderCard(0, cardWidth))881 if cardHeight <= 0 {882 cardHeight = 4883 }884885 fixedHeight := lipgloss.Height(s.renderSettings(width)) + lipgloss.Height(s.renderSummary(width))886 available := max(height-fixedHeight-1, cardHeight)887888 rows := 1889 used := cardHeight890 for used+1+cardHeight <= available {891 rows++892 used += 1 + cardHeight893 }894 if canFitCardRows(available, cardHeight, exportCardTargetRows) {895 rows = max(rows, exportCardTargetRows)896 }897 return rows898}899900func canFitCardRows(available, cardHeight, rows int) bool {901 if available <= 0 || cardHeight <= 0 || rows <= 0 {902 return false903 }904 required := rows * cardHeight905 if rows > 1 {906 required += rows - 1907 }908 return available >= required909}910911func (s *exportModel) view(width, height int) string {912 settings := s.renderSettings(width)913 summary := s.renderSummary(width)914 cards := s.renderCards(width, height)915 return lipgloss.JoinVertical(lipgloss.Left, settings, summary, cards)916}917918func (s *exportModel) contentMetrics(width, height int) exportContentMetrics {919 settings := s.settingsRects(width)920 settingsHeight := settings.TotalHeight921 summaryHeight := lipgloss.Height(s.renderSummary(width))922 cardWidth := exportCardWidth(width, s.cardColumns(width))923 cardHeight := 0924 if len(s.cards) > 0 {925 cardHeight = lipgloss.Height(s.renderCard(0, cardWidth))926 }927 summaryY := settingsHeight928 summaryButton := exportRect{929 X: max(width-lipgloss.Width(renderChoiceChip("Export", s.focus == exportFocusSubmit)), 0),930 Y: summaryY,931 W: lipgloss.Width(renderChoiceChip("Export", s.focus == exportFocusSubmit)),932 H: summaryHeight,933 }934 cardsY := settingsHeight + summaryHeight935 visibleRows := s.visibleCardRows(width, height)936 cardRects := s.cardRects(width, cardsY, cardWidth, cardHeight, visibleRows)937 return exportContentMetrics{938 Width: width,939 Height: height,940 SettingsHeight: settingsHeight,941 SummaryHeight: summaryHeight,942 Settings: settings,943 Summary: exportRect{X: 0, Y: summaryY, W: width, H: summaryHeight},944 SummaryButton: summaryButton,945 CardsRect: exportRect{946 X: 0,947 Y: cardsY,948 W: width,949 H: max(visibleRows*cardHeight+max(visibleRows-1, 0), 0),950 },951 CardsY: cardsY,952 CardWidth: cardWidth,953 CardHeight: cardHeight,954 CardColumns: s.cardColumns(width),955 VisibleRows: visibleRows,956 CardRects: cardRects,957 }958}959960func (s *exportModel) settingsRects(width int) exportSettingsRects {961 if width < 72 {962 return s.stackedSettingsRects(width)963 }964965 y := 0966 inlineHeight := s.inlineFieldHeight(width)967 headerHeight := s.headerFieldHeight(width)968969 titleW := max((width-2)/2, 24)970 advertW := max(width-titleW-2, 24)971 row1 := exportSettingsRects{972 Title: exportRect{X: 0, Y: y, W: titleW, H: inlineHeight},973 Advert: exportRect{X: titleW + 2, Y: y, W: advertW, H: inlineHeight},974 }975 y += inlineHeight976 row1.Header = exportRect{X: 0, Y: y, W: width, H: headerHeight}977 y += headerHeight978979 volumeW := 14980 layoutW := 18981 jsonlW := 14982 seedW := max(width-volumeW-layoutW-jsonlW-6, 22)983 row1.Volume = exportRect{X: 0, Y: y, W: volumeW, H: inlineHeight}984 row1.Layout = exportRect{X: volumeW + 2, Y: y, W: layoutW, H: inlineHeight}985 row1.Seed = exportRect{X: volumeW + layoutW + 4, Y: y, W: seedW, H: inlineHeight}986 row1.JSONL = exportRect{X: volumeW + layoutW + seedW + 6, Y: y, W: jsonlW, H: inlineHeight}987 y += inlineHeight988989 row1.PDFPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}990 y += inlineHeight991992 if s.values.JSONLEnabled {993 row1.HasJSONL = true994 row1.JSONLPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}995 y += inlineHeight996 }997998 row1.TotalHeight = y999 return row11000}10011002func (s *exportModel) stackedSettingsRects(width int) exportSettingsRects {1003 y := 01004 inlineHeight := s.inlineFieldHeight(width)1005 headerHeight := s.headerFieldHeight(width)1006 rects := exportSettingsRects{1007 Title: exportRect{X: 0, Y: y, W: width, H: inlineHeight},1008 }1009 y += inlineHeight1010 rects.Advert = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1011 y += inlineHeight1012 rects.Header = exportRect{X: 0, Y: y, W: width, H: headerHeight}1013 y += headerHeight10141015 volumeW, layoutW, jsonlW := 14, 18, 141016 seedW := max(width-volumeW-layoutW-jsonlW-6, 16)1017 if seedW < 16 {1018 rects.Volume = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1019 y += inlineHeight1020 rects.Layout = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1021 y += inlineHeight1022 rects.Seed = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1023 y += inlineHeight1024 rects.JSONL = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1025 y += inlineHeight1026 } else {1027 rects.Volume = exportRect{X: 0, Y: y, W: volumeW, H: inlineHeight}1028 rects.Layout = exportRect{X: volumeW + 2, Y: y, W: layoutW, H: inlineHeight}1029 rects.Seed = exportRect{X: volumeW + layoutW + 4, Y: y, W: seedW, H: inlineHeight}1030 rects.JSONL = exportRect{X: volumeW + layoutW + seedW + 6, Y: y, W: jsonlW, H: inlineHeight}1031 y += inlineHeight1032 }10331034 rects.PDFPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1035 y += inlineHeight1036 if s.values.JSONLEnabled {1037 rects.HasJSONL = true1038 rects.JSONLPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1039 y += inlineHeight1040 }1041 rects.TotalHeight = y1042 return rects1043}10441045func (s *exportModel) inlineFieldHeight(width int) int {1046 input := s.titleInput1047 return lipgloss.Height(s.renderInlineInputField("Title", &input, false, max(width, 14)))1048}10491050func (s *exportModel) headerFieldHeight(width int) int {1051 input := s.headerInput1052 return lipgloss.Height(s.renderTextareaField("Header", &input, false, max(width, 24)))1053}10541055func (s *exportModel) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {1056 if len(s.cards) == 0 || cardHeight <= 0 {1057 return nil1058 }1059 cols := s.cardColumns(width)1060 start := s.cardRowOffset * cols1061 end := min(len(s.cards), start+visibleRows*cols)1062 layouts := make([]exportCardRects, 0, end-start)1063 for i := start; i < end; i++ {1064 rel := i - start1065 row := rel / cols1066 col := rel % cols1067 rect := exportRect{1068 X: col * (cardWidth + exportCardGap),1069 Y: cardsY + row*(cardHeight+1),1070 W: cardWidth,1071 H: cardHeight,1072 }1073 layouts = append(layouts, exportCardRects{1074 Index: i,1075 Rect: rect,1076 Buckets: s.bucketRectsForCard(i, rect),1077 })1078 }1079 return layouts1080}10811082func (s *exportModel) bucketRectsForCard(index int, rect exportRect) [3]exportRect {1083 var rects [3]exportRect1084 card := s.cards[index]1085 selected := index == s.cardIndex1086 x := rect.X + 21087 y := rect.Y + 21088 for bucketIndex, name := range bucketNames {1089 part := s.renderDifficultyPair(card, selected, bucketIndex, name)1090 w := lipgloss.Width(part)1091 rects[bucketIndex] = exportRect{X: x, Y: y, W: w, H: 1}1092 x += w + 11093 }1094 return rects1095}10961097func (s *exportModel) renderSettings(width int) string {1098 if width < 72 {1099 return s.renderStackedSettings(width)1100 }11011102 titleW := max((width-2)/2, 24)1103 advertW := max(width-titleW-2, 24)1104 row1 := lipgloss.JoinHorizontal(lipgloss.Top,1105 s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, titleW),1106 " ",1107 s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, advertW),1108 )1109 rowHeader := s.renderTextareaField("Header", &s.headerInput, s.focus == exportFocusHeader, width)11101111 volumeW := 141112 layoutW := 181113 jsonlW := 141114 seedW := max(width-volumeW-layoutW-jsonlW-6, 22)1115 row2 := lipgloss.JoinHorizontal(lipgloss.Top,1116 s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, volumeW),1117 " ",1118 s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, layoutW),1119 " ",1120 s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, seedW),1121 " ",1122 s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, jsonlW),1123 )11241125 rows := []string{1126 row1,1127 rowHeader,1128 row2,1129 s.renderInlineInputField("PDF Path", &s.pdfPathInput, s.focus == exportFocusPDFPath, width),1130 }1131 if s.values.JSONLEnabled {1132 rows = append(rows, s.renderInlineInputField("JSONL Path", &s.jsonlPathInput, s.focus == exportFocusJSONLPath, width))1133 }11341135 return lipgloss.JoinVertical(lipgloss.Left, rows...)1136}11371138func (s *exportModel) renderStackedSettings(width int) string {1139 rows := []string{1140 s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, width),1141 s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, width),1142 s.renderTextareaField("Header", &s.headerInput, s.focus == exportFocusHeader, width),1143 }11441145 volumeW, layoutW, jsonlW := 14, 18, 141146 seedW := max(width-volumeW-layoutW-jsonlW-6, 16)1147 if seedW < 16 {1148 rows = append(rows,1149 s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, width),1150 s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, width),1151 s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, width),1152 s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, width),1153 )1154 } else {1155 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top,1156 s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, volumeW),1157 " ",1158 s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, layoutW),1159 " ",1160 s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, seedW),1161 " ",1162 s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, jsonlW),1163 ))1164 }11651166 rows = append(rows, s.renderInlineInputField("PDF Path", &s.pdfPathInput, s.focus == exportFocusPDFPath, width))1167 if s.values.JSONLEnabled {1168 rows = append(rows, s.renderInlineInputField("JSONL Path", &s.jsonlPathInput, s.focus == exportFocusJSONLPath, width))1169 }1170 return lipgloss.JoinVertical(lipgloss.Left, rows...)1171}11721173func (s *exportModel) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {1174 border := theme.Current().Border1175 if focused {1176 border = theme.Current().Accent1177 }11781179 innerWidth := max(width-4, 1)1180 input.SetWidth(innerWidth)1181 input.SetHeight(3)11821183 body := lipgloss.JoinVertical(1184 lipgloss.Left,1185 ui.DimItemStyle().Render(label),1186 input.View(),1187 )1188 return lipgloss.NewStyle().1189 Border(lipgloss.RoundedBorder()).1190 BorderForeground(border).1191 Padding(0, 1).1192 Width(width).1193 Render(body)1194}11951196func (s *exportModel) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {1197 border := theme.Current().Border1198 if focused {1199 border = theme.Current().Accent1200 }12011202 innerWidth := max(width-4, 1)1203 labelWidth := lipgloss.Width(label) + 11204 input.SetWidth(max(innerWidth-labelWidth, 1))12051206 body := lipgloss.JoinHorizontal(1207 lipgloss.Top,1208 ui.DimItemStyle().Render(label),1209 " ",1210 renderSingleLine(input.View(), innerWidth-labelWidth),1211 )1212 return lipgloss.NewStyle().1213 Border(lipgloss.RoundedBorder()).1214 BorderForeground(border).1215 Padding(0, 1).1216 Width(width).1217 Height(1).1218 Render(body)1219}12201221func (s *exportModel) renderInlineChoiceField(label, value string, focused bool, width int) string {1222 border := theme.Current().Border1223 if focused {1224 border = theme.Current().Accent1225 }12261227 innerWidth := max(width-4, 1)1228 labelWidth := lipgloss.Width(label) + 11229 valueStyle := lipgloss.NewStyle().Foreground(theme.Current().FG)1230 if focused {1231 valueStyle = valueStyle.Foreground(theme.Current().Accent).Bold(true)1232 }12331234 body := lipgloss.JoinHorizontal(1235 lipgloss.Top,1236 ui.DimItemStyle().Render(label),1237 " ",1238 renderSingleLine(valueStyle.Render(value), innerWidth-labelWidth),1239 )1240 return lipgloss.NewStyle().1241 Border(lipgloss.RoundedBorder()).1242 BorderForeground(border).1243 Padding(0, 1).1244 Width(width).1245 Height(1).1246 Render(body)1247}12481249func renderSingleLine(value string, width int) string {1250 if width <= 0 {1251 return ""1252 }1253 return lipgloss.NewStyle().1254 Width(width).1255 MaxWidth(width).1256 Height(1).1257 MaxHeight(1).1258 Render(value)1259}12601261func renderChoiceChip(label string, focused bool) string {1262 style := lipgloss.NewStyle().1263 Bold(true).1264 Padding(0, 1).1265 Foreground(theme.Current().FG).1266 Border(lipgloss.NormalBorder()).1267 BorderForeground(theme.Current().Border)1268 if focused {1269 style = style.Foreground(theme.TextOnBG(theme.Current().Accent)).Background(theme.Current().Accent).BorderForeground(theme.Current().Accent)1270 }1271 return style.Render(label)1272}12731274func layoutLabel(layout string) string {1275 switch layout {1276 case "duplex-booklet":1277 return "Booklet"1278 default:1279 return "Half Letter"1280 }1281}12821283func onOffLabel(enabled bool) string {1284 if enabled {1285 return "On"1286 }1287 return "Off"1288}12891290func (s *exportModel) renderSummary(width int) string {1291 total := 01292 for _, card := range s.cards {1293 for _, count := range card.Buckets {1294 total += count1295 }1296 }12971298 selected := "none"1299 seedHint := ""1300 if len(s.cards) > 0 {1301 card := s.cards[s.cardIndex]1302 selected = fmt.Sprintf("%s / %s = %d", card.GameType, bucketNames[s.bucketIndex], card.Buckets[s.bucketIndex])1303 if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {1304 seedHint = " selected bucket cannot be seeded"1305 }1306 }13071308 button := renderChoiceChip("Export", s.focus == exportFocusSubmit)1309 totalText := ui.PanelTitle().Render(fmt.Sprintf("Total: %d", total))1310 detailWidth := max(width-lipgloss.Width(totalText)-lipgloss.Width(button)-4, 12)1311 detailText := ui.DimItemStyle().Render(truncateLabel("Selected: "+selected+seedHint, detailWidth))1312 detail := lipgloss.NewStyle().Width(detailWidth).MaxWidth(detailWidth).Render(detailText)1313 return lipgloss.JoinHorizontal(lipgloss.Top, totalText, " ", detail, " ", button)1314}13151316var bucketNames = [3]string{"Easy", "Medium", "Hard"}13171318func (s *exportModel) renderCards(width, height int) string {1319 if len(s.cards) == 0 {1320 return ui.DimItemStyle().Render("No exportable games found.")1321 }13221323 cols := s.cardColumns(width)1324 cardWidth := exportCardWidth(width, cols)1325 start := s.cardRowOffset * cols1326 end := min(len(s.cards), start+s.visibleCardRows(width, height)*cols)13271328 rows := []string{}1329 for i := start; i < end; i += cols {1330 rowEnd := min(i+cols, end)1331 parts := make([]string, 0, rowEnd-i)1332 for idx := i; idx < rowEnd; idx++ {1333 parts = append(parts, s.renderCard(idx, cardWidth))1334 }1335 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, joinCardParts(parts)...))1336 }13371338 footer := ""1339 totalRows := max((len(s.cards)+cols-1)/cols, 1)1340 if totalRows > s.visibleCardRows(width, height) {1341 footer = ui.DimItemStyle().Render(fmt.Sprintf("rows %d-%d of %d", s.cardRowOffset+1, min(s.cardRowOffset+s.visibleCardRows(width, height), totalRows), totalRows))1342 }13431344 content := strings.Join(rows, "\n")1345 if footer != "" {1346 content = lipgloss.JoinVertical(lipgloss.Left, content, footer)1347 }1348 return content1349}13501351func (s *exportModel) renderCard(index, width int) string {1352 card := s.cards[index]1353 selected := index == s.cardIndex13541355 border := theme.Current().Border1356 if selected {1357 border = theme.Current().Accent1358 }13591360 titleStyle := ui.PanelTitle()1361 if selected {1362 titleStyle = ui.SelectedItemStyle()1363 }13641365 bucketParts := make([]string, 0, 3)1366 for bucketIndex, name := range bucketNames {1367 bucketParts = append(bucketParts, s.renderDifficultyPair(card, selected, bucketIndex, name))1368 }13691370 total := card.Buckets[0] + card.Buckets[1] + card.Buckets[2]1371 titleWidth := max(width-10, 6)1372 title := truncateLabel(card.GameType, titleWidth)1373 titleLine := lipgloss.JoinHorizontal(1374 lipgloss.Top,1375 titleStyle.Width(titleWidth).Render(title),1376 " ",1377 ui.DimItemStyle().Render(fmt.Sprintf("%d", total)),1378 )13791380 body := lipgloss.JoinVertical(1381 lipgloss.Left,1382 titleLine,1383 lipgloss.JoinHorizontal(lipgloss.Top, bucketParts[0], " ", bucketParts[1], " ", bucketParts[2]),1384 )13851386 return lipgloss.NewStyle().1387 Border(lipgloss.RoundedBorder()).1388 BorderForeground(border).1389 Padding(0, 1).1390 Width(width).1391 Render(body)1392}13931394func (s *exportModel) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {1395 palette := theme.Current()1396 bg, fg := exportDifficultyColors(bucketIndex, palette)1397 pairStyle := lipgloss.NewStyle().1398 Foreground(fg).1399 Background(bg).1400 Bold(true).1401 Padding(0, 0)1402 if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(bucketIndex) {1403 pairStyle = pairStyle.Foreground(palette.TextDim).Background(theme.Blend(palette.BG, bg, 0.35))1404 }14051406 pair := pairStyle.Render(fmt.Sprintf("%s%d", string(bucketName[0]), card.Buckets[bucketIndex]))1407 if cardSelected && s.focus == exportFocusCards && bucketIndex == s.bucketIndex {1408 return puzzlegame.CursorStyle().Render(puzzlegame.CursorLeft) + pair + puzzlegame.CursorStyle().Render(puzzlegame.CursorRight)1409 }1410 return pair1411}14121413func (s *exportModel) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, bool) {1414 mouse := msg.Mouse()1415 content := s.view(contentWidth, contentHeight)1416 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)1417 localX := mouse.X - contentX1418 localY := mouse.Y - contentY1419 if localX < 0 || localY < 0 {1420 return nil, false1421 }14221423 metrics := s.contentMetrics(contentWidth, contentHeight)1424 if cmd, handled := s.handleSettingsClick(localX, localY, metrics); handled {1425 return cmd, true1426 }1427 if metrics.SummaryButton.contains(localX, localY) {1428 s.focus = exportFocusSubmit1429 return tea.Batch(s.applyFocus(), exportSubmitCmd), true1430 }1431 if cmd, handled := s.handleCardClick(localX, localY, metrics); handled {1432 return cmd, true1433 }1434 return nil, false1435}14361437func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (panelX, panelY, contentX, contentY int) {1438 panel := ui.Panel("Export", content, exportFooterHint)1439 panelWidth := lipgloss.Width(panel)1440 panelHeight := lipgloss.Height(panel)1441 panelX = max((screenWidth-panelWidth)/2, 0)1442 panelY = max((screenHeight-panelHeight)/2, 0)14431444 frame := ui.PanelFrame()1445 contentX = panelX + frame.GetHorizontalFrameSize()/21446 contentY = panelY + 1 + 2 + 21447 return panelX, panelY, contentX, contentY1448}14491450func (s *exportModel) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {1451 mouse := msg.Mouse()1452 content := s.view(contentWidth, contentHeight)1453 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)1454 localX := mouse.X - contentX1455 localY := mouse.Y - contentY1456 if localX < 0 || localY < 0 {1457 return false1458 }1459 metrics := s.contentMetrics(contentWidth, contentHeight)1460 if !metrics.CardsRect.contains(localX, localY) {1461 return false1462 }1463 totalRows := max((len(s.cards)+metrics.CardColumns-1)/metrics.CardColumns, 1)1464 if totalRows <= metrics.VisibleRows {1465 return false1466 }1467 switch mouse.Button {1468 case tea.MouseWheelDown:1469 s.cardRowOffset = min(s.cardRowOffset+1, totalRows-metrics.VisibleRows)1470 case tea.MouseWheelUp:1471 s.cardRowOffset = max(s.cardRowOffset-1, 0)1472 default:1473 return false1474 }1475 s.clampSelectionToVisibleRows(metrics.CardColumns, metrics.VisibleRows)1476 return true1477}14781479func (s *exportModel) clampSelectionToVisibleRows(cols, visibleRows int) {1480 if len(s.cards) == 0 || cols <= 0 || visibleRows <= 0 {1481 return1482 }1483 row := s.cardIndex / cols1484 col := s.cardIndex % cols1485 if row < s.cardRowOffset {1486 row = s.cardRowOffset1487 }1488 maxVisibleRow := s.cardRowOffset + visibleRows - 11489 if row > maxVisibleRow {1490 row = maxVisibleRow1491 }1492 index := row*cols + col1493 if index >= len(s.cards) {1494 index = len(s.cards) - 11495 }1496 s.cardIndex = max(index, 0)1497}14981499func (s *exportModel) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1500 settings := metrics.Settings1501 switch {1502 case settings.Title.contains(localX, localY):1503 return s.focusTextInput(exportFocusTitle, &s.titleInput, settings.Title, "Title", localX), true1504 case settings.Advert.contains(localX, localY):1505 return s.focusTextInput(exportFocusAdvert, &s.advertInput, settings.Advert, "Advert", localX), true1506 case settings.Header.contains(localX, localY):1507 return s.focusTextarea(settings.Header, localX, localY), true1508 case settings.Volume.contains(localX, localY):1509 return s.focusTextInput(exportFocusVolume, &s.volumeInput, settings.Volume, "Volume", localX), true1510 case settings.Layout.contains(localX, localY):1511 s.focus = exportFocusLayout1512 s.cycleLayout(1)1513 return s.applyFocus(), true1514 case settings.Seed.contains(localX, localY):1515 return s.focusTextInput(exportFocusSeed, &s.seedInput, settings.Seed, "Seed", localX), true1516 case settings.JSONL.contains(localX, localY):1517 s.focus = exportFocusJSONLToggle1518 s.toggleJSONL()1519 return s.applyFocus(), true1520 case settings.PDFPath.contains(localX, localY):1521 return s.focusTextInput(exportFocusPDFPath, &s.pdfPathInput, settings.PDFPath, "PDF Path", localX), true1522 case settings.HasJSONL && settings.JSONLPath.contains(localX, localY):1523 return s.focusTextInput(exportFocusJSONLPath, &s.jsonlPathInput, settings.JSONLPath, "JSONL Path", localX), true1524 default:1525 return nil, false1526 }1527}15281529func (s *exportModel) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {1530 s.focus = focus1531 labelWidth := lipgloss.Width(label) + 11532 cursorPos := max(localX-rect.X-2-labelWidth, 0)1533 input.SetCursor(cursorPos)1534 return s.applyFocus()1535}15361537func (s *exportModel) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {1538 s.focus = exportFocusHeader1539 line := max(localY-rect.Y-2, 0)1540 col := max(localX-rect.X-2, 0)1541 maxLine := max(s.headerInput.LineCount()-1, 0)1542 line = min(line, maxLine)1543 s.headerInput.MoveToBegin()1544 for i := 0; i < line; i++ {1545 s.headerInput.CursorDown()1546 }1547 s.headerInput.SetCursorColumn(col)1548 return s.applyFocus()1549}15501551func (s *exportModel) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1552 if len(s.cards) == 0 {1553 return nil, false1554 }1555 for _, layout := range metrics.CardRects {1556 if !layout.Rect.contains(localX, localY) {1557 continue1558 }1559 for bucketIndex, bucket := range layout.Buckets {1560 if bucket.contains(localX, localY) {1561 return s.selectCardBucket(layout.Index, bucketIndex, metrics), true1562 }1563 }1564 return s.selectCardBucket(layout.Index, s.bucketIndex, metrics), true1565 }1566 return nil, false1567}15681569func (s *exportModel) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {1570 s.focus = exportFocusCards1571 s.cardIndex = cardIndex1572 s.bucketIndex = min(max(bucketIndex, 0), len(bucketNames)-1)1573 s.ensureCardSelectionVisible(metrics.Width, metrics.Height)1574 return s.applyFocus()1575}15761577type exportContentMetrics struct {1578 Width int1579 Height int1580 SettingsHeight int1581 SummaryHeight int1582 Settings exportSettingsRects1583 Summary exportRect1584 SummaryButton exportRect1585 CardsRect exportRect1586 CardsY int1587 CardWidth int1588 CardHeight int1589 CardColumns int1590 VisibleRows int1591 CardRects []exportCardRects1592}15931594func exportDifficultyColors(bucketIndex int, palette theme.Palette) (bg, fg color.Color) {1595 switch bucketIndex {1596 case 0:1597 bg = palette.ErrorBG1598 case 1:1599 bg = palette.SuccessBG1600 default:1601 bg = theme.Blend(palette.BG, palette.Secondary, 0.35)1602 }1603 fg = theme.TextOnBG(bg)1604 return bg, fg1605}16061607func exportCardWidth(width, cols int) int {1608 if cols <= 0 {1609 return width1610 }1611 return max((width-(cols-1)*exportCardGap)/cols, exportCardMinWidth)1612}16131614func joinCardParts(parts []string) []string {1615 if len(parts) == 0 {1616 return nil1617 }1618 joined := make([]string, 0, len(parts)*2-1)1619 for i, part := range parts {1620 if i > 0 {1621 joined = append(joined, strings.Repeat(" ", exportCardGap))1622 }1623 joined = append(joined, part)1624 }1625 return joined1626}16271628func truncateLabel(s string, width int) string {1629 if width <= 0 {1630 return ""1631 }1632 runes := []rune(s)1633 if len(runes) <= width {1634 return s1635 }1636 if width == 1 {1637 return string(runes[:1])1638 }1639 return string(runes[:width-1]) + "…"1640}16411642func (s exportModel) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {1643 sync := s1644 sync.syncValuesFromInputs()16451646 spec := sync.values.toExactSpec()1647 spec.Counts = expandCardCounts(sync.cards)1648 if !sync.values.JSONLEnabled {1649 spec.JSONLOutputPath = ""1650 }16511652 cfg := config.ExportConfig{1653 Title: spec.Title,1654 Header: spec.Header,1655 Advert: spec.Advert,1656 Volume: spec.Volume,1657 SheetLayout: spec.SheetLayout,1658 Seed: spec.Seed,1659 PDFOutputPath: spec.PDFOutputPath,1660 JSONLEnabled: sync.values.JSONLEnabled,1661 JSONLOutputPath: sync.values.JSONLOutputPath,1662 Counts: spec.Counts,1663 }1664 return spec, cfg1665}16661667func (s *exportModel) syncValuesFromInputs() {1668 s.values.Title = s.titleInput.Value()1669 s.values.Header = s.headerInput.Value()1670 s.values.Advert = s.advertInput.Value()1671 s.values.Volume = s.volumeInput.Value()1672 s.values.Seed = s.seedInput.Value()1673 s.values.PDFOutputPath = s.pdfPathInput.Value()1674 s.values.JSONLOutputPath = s.jsonlPathInput.Value()1675}16761677func (v exportFormValues) toExactSpec() packexport.Spec {1678 volume, err := strconv.Atoi(strings.TrimSpace(v.Volume))1679 if err != nil {1680 volume = -11681 }16821683 spec := packexport.Spec{1684 Title: strings.TrimSpace(v.Title),1685 Header: strings.TrimSpace(v.Header),1686 Advert: strings.TrimSpace(v.Advert),1687 Volume: volume,1688 SheetLayout: strings.TrimSpace(v.SheetLayout),1689 Seed: strings.TrimSpace(v.Seed),1690 PDFOutputPath: strings.TrimSpace(v.PDFOutputPath),1691 Counts: map[puzzle.GameID]map[puzzle.ModeID]int{},1692 }1693 if v.JSONLEnabled {1694 spec.JSONLOutputPath = strings.TrimSpace(v.JSONLOutputPath)1695 }1696 return spec1697}16981699func expandCardCounts(cards []exportGameCard) map[puzzle.GameID]map[puzzle.ModeID]int {1700 counts := make(map[puzzle.GameID]map[puzzle.ModeID]int, len(cards))1701 for _, card := range cards {1702 if _, ok := counts[card.GameID]; !ok {1703 counts[card.GameID] = make(map[puzzle.ModeID]int)1704 }1705 for bucketIndex, total := range card.Buckets {1706 modes := card.BucketModes[bucketIndex]1707 if len(modes) == 0 {1708 continue1709 }1710 base := total / len(modes)1711 remainder := total % len(modes)1712 for i, mode := range modes {1713 count := base1714 if i < remainder {1715 count++1716 }1717 counts[mode.GameID][mode.ModeID] = count1718 }1719 }1720 }1721 return counts1722}17231724// runeIdxBeforeExt returns the rune index just before the file extension so1725// that focusing a path field via tab lands the cursor there. This lets users1726// type a new base-name suffix (e.g. "2") without overwriting ".pdf"/".jsonl".1727func runeIdxBeforeExt(path string) int {1728 ext := filepath.Ext(path)1729 runes := []rune(path)1730 extRunes := len([]rune(ext))1731 if extRunes == 0 || extRunes >= len(runes) {1732 return len(runes)1733 }1734 return len(runes) - extRunes1735}