clone

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

feat/app-decomposition
puzzletea / app / export.go
export.go 47.9K
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}325326func (m *model) cancelActiveExport() {327 es, ok := m.screens[exportView].(exportScreen)328 if !ok {329 return330 }331 if es.cancel != nil {332 es.cancel()333 es.cancel = nil334 }335 if es.running {336 es.jobID++337 }338 es.running = false339 m.screens[exportView] = es340}341342func (m *model) startExport(spec packexport.Spec) tea.Cmd {343 m.cancelActiveExport()344 es, _ := m.screens[exportView].(exportScreen)345 es.jobID++346 jobID := es.jobID347 ctx, cancel := context.WithCancel(context.Background())348 es.cancel = cancel349 es.running = true350 m.screens[exportView] = es351 m.state = exportRunningView352 *m = m.clearNotice()353 *m = m.initScreen(exportRunningView)354 return tea.Batch(m.spinner.Tick, exportCmd(ctx, jobID, spec))355}356357func exportCmd(ctx context.Context, jobID int64, spec packexport.Spec) tea.Cmd {358 return func() tea.Msg {359 result, err := packexport.Run(ctx, spec)360 return exportCompleteMsg{jobID: jobID, result: result, err: err}361 }362}363364func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportModel {365 contentWidth, _ := exportContentBounds(width, 0)366 state := exportModel{367 focus: exportFocusCards,368 values: values,369 cards: cards,370 width: width,371 }372 state.initInputs(contentWidth)373 return state374}375376func exportContentBounds(width, height int) (int, int) {377 return max(width-exportPanelWidthPadding, 24), max(height-exportPanelHeightPadding, 8)378}379380func (m model) initialExportSpec() packexport.Spec {381 cwd, err := os.Getwd()382 if err != nil {383 cwd = "."384 }385386 base := packexport.DefaultSpec(cwd)387 if !hasSavedExportConfig(m.cfg.Export) {388 return base389 }390391 return applySavedExportConfig(base, m.cfg.Export)392}393394func hasSavedExportConfig(cfg config.ExportConfig) bool {395 return cfg.Title != "" ||396 cfg.Header != "" ||397 cfg.Advert != "" ||398 cfg.Volume != 0 ||399 cfg.SheetLayout != "" ||400 cfg.Seed != "" ||401 cfg.PDFOutputPath != "" ||402 cfg.JSONLOutputPath != "" ||403 cfg.JSONLEnabled ||404 len(cfg.Counts) > 0405}406407func applySavedExportConfig(base packexport.Spec, cfg config.ExportConfig) packexport.Spec {408 if cfg.Title != "" {409 base.Title = cfg.Title410 }411 if cfg.Header != "" {412 base.Header = cfg.Header413 }414 if cfg.Advert != "" {415 base.Advert = cfg.Advert416 }417 if cfg.Volume > 0 {418 base.Volume = cfg.Volume419 }420 if cfg.SheetLayout != "" {421 base.SheetLayout = cfg.SheetLayout422 }423 base.Seed = cfg.Seed424 if cfg.PDFOutputPath != "" {425 base.PDFOutputPath = cfg.PDFOutputPath426 }427 if cfg.JSONLEnabled {428 base.JSONLOutputPath = cfg.JSONLOutputPath429 } else {430 base.JSONLOutputPath = ""431 }432 for gameID, modes := range cfg.Counts {433 if _, ok := base.Counts[gameID]; !ok {434 base.Counts[gameID] = map[puzzle.ModeID]int{}435 }436 for modeID, count := range modes {437 base.Counts[gameID][modeID] = count438 }439 }440 return base441}442443func exportValuesFromSpec(spec packexport.Spec) exportFormValues {444 values := exportFormValues{445 Title: spec.Title,446 Header: spec.Header,447 Advert: spec.Advert,448 Volume: strconv.Itoa(spec.Volume),449 SheetLayout: spec.SheetLayout,450 Seed: spec.Seed,451 PDFOutputPath: spec.PDFOutputPath,452 JSONLEnabled: strings.TrimSpace(spec.JSONLOutputPath) != "",453 JSONLOutputPath: defaultJSONLPath(spec),454 }455 if strings.TrimSpace(spec.JSONLOutputPath) != "" {456 values.JSONLOutputPath = spec.JSONLOutputPath457 }458 return values459}460461func defaultJSONLPath(spec packexport.Spec) string {462 dir := filepath.Dir(spec.PDFOutputPath)463 if dir == "." || dir == "" {464 dir = "out"465 }466 return filepath.Join(dir, "puzzletea-export.jsonl")467}468469func buildExportCardsFromSpec(spec packexport.Spec) []exportGameCard {470 catalog := packexport.ExportCatalog()471 cards := make([]exportGameCard, 0, len(catalog))472 for _, game := range catalog {473 card := exportGameCard{474 GameID: game.GameID,475 GameType: game.GameType,476 }477 for i, mode := range game.Modes {478 bucket := bucketIndexForMode(i, len(game.Modes))479 card.BucketModes[bucket] = append(card.BucketModes[bucket], mode)480 if modes, ok := spec.Counts[mode.GameID]; ok {481 card.Buckets[bucket] += modes[mode.ModeID]482 }483 }484 cards = append(cards, card)485 }486 return cards487}488489func bucketIndexForMode(index, total int) int {490 if total <= 0 {491 return 0492 }493 return index * 3 / total494}495496func (s *exportModel) initInputs(width int) {497 s.titleInput = newExportInput(s.values.Title, "Sampler title")498 s.headerInput = newExportTextarea(s.values.Header, "Short intro")499 s.advertInput = newExportInput(s.values.Advert, "Footer copy")500 s.volumeInput = newExportInput(s.values.Volume, "1")501 s.seedInput = newExportInput(s.values.Seed, "optional seed")502 s.pdfPathInput = newExportInput(s.values.PDFOutputPath, "output pdf")503 s.jsonlPathInput = newExportInput(s.values.JSONLOutputPath, "optional jsonl")504 s.resize(width)505 s.applyFocus()506}507508func newExportInput(value, placeholder string) textinput.Model {509 input := textinput.New()510 input.Prompt = ""511 input.SetValue(value)512 input.Placeholder = placeholder513 input.CharLimit = 512514 styleExportInput(&input)515 return input516}517518func newExportTextarea(value, placeholder string) textarea.Model {519 input := textarea.New()520 input.Prompt = ""521 input.SetValue(value)522 input.Placeholder = placeholder523 input.CharLimit = 512524 input.ShowLineNumbers = false525 input.EndOfBufferCharacter = ' '526 input.SetHeight(3)527 styleExportTextarea(&input)528 return input529}530531func styleExportInput(input *textinput.Model) {532 palette := theme.Current()533 styles := input.Styles()534 styles.Focused.Text = lipgloss.NewStyle().Foreground(palette.FG)535 styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)536 styles.Focused.Prompt = lipgloss.NewStyle().Foreground(palette.Accent)537 styles.Blurred.Text = lipgloss.NewStyle().Foreground(palette.FG)538 styles.Blurred.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)539 styles.Blurred.Prompt = lipgloss.NewStyle().Foreground(palette.TextDim)540 styles.Cursor.Color = palette.Accent541 input.SetStyles(styles)542}543544func styleExportTextarea(input *textarea.Model) {545 palette := theme.Current()546 styles := input.Styles()547 styles.Focused.Base = lipgloss.NewStyle()548 styles.Focused.Text = lipgloss.NewStyle().Foreground(palette.FG)549 styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)550 styles.Focused.Prompt = lipgloss.NewStyle().Foreground(palette.Accent)551 styles.Focused.CursorLine = lipgloss.NewStyle()552 styles.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(palette.TextDim)553 styles.Blurred.Base = lipgloss.NewStyle()554 styles.Blurred.Text = lipgloss.NewStyle().Foreground(palette.FG)555 styles.Blurred.Placeholder = lipgloss.NewStyle().Foreground(palette.TextDim)556 styles.Blurred.Prompt = lipgloss.NewStyle().Foreground(palette.TextDim)557 styles.Blurred.CursorLine = lipgloss.NewStyle()558 styles.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(palette.TextDim)559 styles.Cursor.Color = palette.Accent560 input.SetStyles(styles)561}562563func (s *exportModel) resize(width int) {564 styleExportInput(&s.titleInput)565 styleExportTextarea(&s.headerInput)566 styleExportInput(&s.advertInput)567 styleExportInput(&s.volumeInput)568 styleExportInput(&s.seedInput)569 styleExportInput(&s.pdfPathInput)570 styleExportInput(&s.jsonlPathInput)571572 fieldW := max(18, min((width-22)/3, 38))573 s.titleInput.SetWidth(fieldW)574 s.headerInput.SetWidth(fieldW)575 s.headerInput.SetHeight(3)576 s.advertInput.SetWidth(fieldW)577 s.volumeInput.SetWidth(6)578 s.seedInput.SetWidth(max(18, min(fieldW, 26)))579 s.pdfPathInput.SetWidth(max(26, min(width-20, 72)))580 s.jsonlPathInput.SetWidth(max(26, min(width-20, 72)))581}582583func (s *exportModel) visibleFocuses() []exportFocus {584 focuses := []exportFocus{585 exportFocusTitle,586 exportFocusAdvert,587 exportFocusHeader,588 exportFocusVolume,589 exportFocusLayout,590 exportFocusSeed,591 exportFocusPDFPath,592 exportFocusJSONLToggle,593 }594 if s.values.JSONLEnabled {595 focuses = append(focuses, exportFocusJSONLPath)596 }597 focuses = append(focuses, exportFocusCards, exportFocusSubmit)598 return focuses599}600601func (s *exportModel) moveFocus(delta int) tea.Cmd {602 focuses := s.visibleFocuses()603 current := 0604 for i, focus := range focuses {605 if focus == s.focus {606 current = i607 break608 }609 }610 next := (current + delta) % len(focuses)611 if next < 0 {612 next += len(focuses)613 }614 s.focus = focuses[next]615 switch s.focus {616 case exportFocusPDFPath:617 s.pdfPathInput.SetCursor(runeIdxBeforeExt(s.values.PDFOutputPath))618 case exportFocusJSONLPath:619 s.jsonlPathInput.SetCursor(runeIdxBeforeExt(s.values.JSONLOutputPath))620 }621 return s.applyFocus()622}623624func (s *exportModel) applyFocus() tea.Cmd {625 s.titleInput.Blur()626 s.headerInput.Blur()627 s.advertInput.Blur()628 s.volumeInput.Blur()629 s.seedInput.Blur()630 s.pdfPathInput.Blur()631 s.jsonlPathInput.Blur()632633 switch s.focus {634 case exportFocusTitle:635 return s.titleInput.Focus()636 case exportFocusHeader:637 return s.headerInput.Focus()638 case exportFocusAdvert:639 return s.advertInput.Focus()640 case exportFocusVolume:641 return s.volumeInput.Focus()642 case exportFocusSeed:643 return s.seedInput.Focus()644 case exportFocusPDFPath:645 return s.pdfPathInput.Focus()646 case exportFocusJSONLPath:647 return s.jsonlPathInput.Focus()648 default:649 return nil650 }651}652653func (s *exportModel) updateFocusedInput(msg tea.Msg) tea.Cmd {654 var cmd tea.Cmd655 switch s.focus {656 case exportFocusTitle:657 s.titleInput, cmd = s.titleInput.Update(msg)658 s.values.Title = s.titleInput.Value()659 case exportFocusHeader:660 s.headerInput, cmd = s.headerInput.Update(msg)661 s.values.Header = s.headerInput.Value()662 case exportFocusAdvert:663 s.advertInput, cmd = s.advertInput.Update(msg)664 s.values.Advert = s.advertInput.Value()665 case exportFocusVolume:666 s.volumeInput, cmd = s.volumeInput.Update(msg)667 s.values.Volume = s.volumeInput.Value()668 case exportFocusSeed:669 s.seedInput, cmd = s.seedInput.Update(msg)670 s.values.Seed = s.seedInput.Value()671 case exportFocusPDFPath:672 s.pdfPathInput, cmd = s.pdfPathInput.Update(msg)673 s.values.PDFOutputPath = s.pdfPathInput.Value()674 case exportFocusJSONLPath:675 s.jsonlPathInput, cmd = s.jsonlPathInput.Update(msg)676 s.values.JSONLOutputPath = s.jsonlPathInput.Value()677 }678 return cmd679}680681func (s *exportModel) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {682 switch s.focus {683 case exportFocusLayout:684 switch msg.String() {685 case "left", "h":686 s.cycleLayout(-1)687 return true688 case "right", "l":689 s.cycleLayout(1)690 return true691 }692 case exportFocusJSONLToggle:693 switch msg.String() {694 case "left", "right", "h", "l", " ":695 s.toggleJSONL()696 return true697 }698 case exportFocusCards:699 switch msg.String() {700 case "left":701 s.moveBucketSelection(-1, width, height)702 return true703 case "right":704 s.moveBucketSelection(1, width, height)705 return true706 case "h":707 s.moveBucketSelection(-1, width, height)708 return true709 case "l":710 s.moveBucketSelection(1, width, height)711 return true712 case "up", "k":713 cols := s.cardColumns(width)714 if s.cardIndex-cols >= 0 {715 s.cardIndex -= cols716 }717 s.ensureCardSelectionVisible(width, height)718 return true719 case "down", "j":720 cols := s.cardColumns(width)721 if s.cardIndex+cols < len(s.cards) {722 s.cardIndex += cols723 }724 s.ensureCardSelectionVisible(width, height)725 return true726 case "+", "=":727 s.adjustSelectedBucket(1)728 return true729 case "-":730 s.adjustSelectedBucket(-1)731 return true732 case "backspace", "delete":733 s.backspaceSelectedBucket()734 return true735 case "pgup":736 s.cardRowOffset = max(s.cardRowOffset-1, 0)737 return true738 case "pgdown":739 s.cardRowOffset++740 s.ensureCardSelectionVisible(width, height)741 return true742 case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":743 s.appendSelectedBucketDigit(msg.String())744 return true745 }746 }747 return false748}749750func (s *exportModel) moveBucketSelection(delta, width, height int) {751 if len(s.cards) == 0 || delta == 0 {752 return753 }754 position := s.cardIndex*len(bucketNames) + s.bucketIndex + delta755 maxPosition := len(s.cards)*len(bucketNames) - 1756 position = max(0, min(position, maxPosition))757 s.cardIndex = position / len(bucketNames)758 s.bucketIndex = position % len(bucketNames)759 s.ensureCardSelectionVisible(width, height)760}761762func (s *exportModel) appendSelectedBucketDigit(raw string) {763 if len(s.cards) == 0 {764 return765 }766 digit, err := strconv.Atoi(raw)767 if err != nil {768 return769 }770 card := &s.cards[s.cardIndex]771 if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {772 return773 }774 card.Buckets[s.bucketIndex] = card.Buckets[s.bucketIndex]*10 + digit775}776777func (s *exportModel) backspaceSelectedBucket() {778 if len(s.cards) == 0 {779 return780 }781 card := &s.cards[s.cardIndex]782 card.Buckets[s.bucketIndex] /= 10783}784785func (s *exportModel) cycleLayout(delta int) {786 index := 0787 for i, layout := range exportSheetLayouts {788 if layout == s.values.SheetLayout {789 index = i790 break791 }792 }793 index = (index + delta) % len(exportSheetLayouts)794 if index < 0 {795 index += len(exportSheetLayouts)796 }797 s.values.SheetLayout = exportSheetLayouts[index]798}799800func (s *exportModel) toggleJSONL() {801 s.values.JSONLEnabled = !s.values.JSONLEnabled802 if !s.values.JSONLEnabled && s.focus == exportFocusJSONLPath {803 s.focus = exportFocusCards804 }805}806807func (s *exportModel) adjustSelectedBucket(delta int) {808 if len(s.cards) == 0 {809 return810 }811 card := &s.cards[s.cardIndex]812 if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {813 return814 }815 card.Buckets[s.bucketIndex] = max(card.Buckets[s.bucketIndex]+delta, 0)816}817818func (c exportGameCard) bucketSeeded(index int) bool {819 if index < 0 || index >= len(c.BucketModes) {820 return true821 }822 if len(c.BucketModes[index]) == 0 {823 return true824 }825 for _, mode := range c.BucketModes[index] {826 if !mode.Seeded {827 return false828 }829 }830 return true831}832833func (s *exportModel) cardColumns(width int) int {834 for cols := exportCardMaxColumns; cols >= 1; cols-- {835 if exportCardWidth(width, cols) >= exportCardMinWidth {836 return cols837 }838 }839 return 1840}841842func (s *exportModel) ensureCardSelectionVisible(width, height int) {843 cols := s.cardColumns(width)844 if cols <= 0 {845 return846 }847 selectedRow := s.cardIndex / cols848 visibleRows := s.visibleCardRows(width, height)849 if selectedRow < s.cardRowOffset {850 s.cardRowOffset = selectedRow851 }852 if selectedRow >= s.cardRowOffset+visibleRows {853 s.cardRowOffset = selectedRow - visibleRows + 1854 }855 maxRows := max((len(s.cards)+cols-1)/cols, 1)856 s.cardRowOffset = min(max(s.cardRowOffset, 0), maxRows-1)857}858859func (s *exportModel) visibleCardRows(width, height int) int {860 if len(s.cards) == 0 {861 return 1862 }863864 cardWidth := exportCardWidth(width, s.cardColumns(width))865 cardHeight := lipgloss.Height(s.renderCard(0, cardWidth))866 if cardHeight <= 0 {867 cardHeight = 4868 }869870 fixedHeight := lipgloss.Height(s.renderSettings(width)) + lipgloss.Height(s.renderSummary(width))871 available := max(height-fixedHeight-1, cardHeight)872873 rows := 1874 used := cardHeight875 for used+1+cardHeight <= available {876 rows++877 used += 1 + cardHeight878 }879 if canFitCardRows(available, cardHeight, exportCardTargetRows) {880 rows = max(rows, exportCardTargetRows)881 }882 return rows883}884885func canFitCardRows(available, cardHeight, rows int) bool {886 if available <= 0 || cardHeight <= 0 || rows <= 0 {887 return false888 }889 required := rows * cardHeight890 if rows > 1 {891 required += rows - 1892 }893 return available >= required894}895896func (s *exportModel) view(width, height int) string {897 settings := s.renderSettings(width)898 summary := s.renderSummary(width)899 cards := s.renderCards(width, height)900 return lipgloss.JoinVertical(lipgloss.Left, settings, summary, cards)901}902903func (s *exportModel) contentMetrics(width, height int) exportContentMetrics {904 settings := s.settingsRects(width)905 settingsHeight := settings.TotalHeight906 summaryHeight := lipgloss.Height(s.renderSummary(width))907 cardWidth := exportCardWidth(width, s.cardColumns(width))908 cardHeight := 0909 if len(s.cards) > 0 {910 cardHeight = lipgloss.Height(s.renderCard(0, cardWidth))911 }912 summaryY := settingsHeight913 summaryButton := exportRect{914 X: max(width-lipgloss.Width(renderChoiceChip("Export", s.focus == exportFocusSubmit)), 0),915 Y: summaryY,916 W: lipgloss.Width(renderChoiceChip("Export", s.focus == exportFocusSubmit)),917 H: summaryHeight,918 }919 cardsY := settingsHeight + summaryHeight920 visibleRows := s.visibleCardRows(width, height)921 cardRects := s.cardRects(width, cardsY, cardWidth, cardHeight, visibleRows)922 return exportContentMetrics{923 Width: width,924 Height: height,925 SettingsHeight: settingsHeight,926 SummaryHeight: summaryHeight,927 Settings: settings,928 Summary: exportRect{X: 0, Y: summaryY, W: width, H: summaryHeight},929 SummaryButton: summaryButton,930 CardsRect: exportRect{931 X: 0,932 Y: cardsY,933 W: width,934 H: max(visibleRows*cardHeight+max(visibleRows-1, 0), 0),935 },936 CardsY: cardsY,937 CardWidth: cardWidth,938 CardHeight: cardHeight,939 CardColumns: s.cardColumns(width),940 VisibleRows: visibleRows,941 CardRects: cardRects,942 }943}944945func (s *exportModel) settingsRects(width int) exportSettingsRects {946 if width < 72 {947 return s.stackedSettingsRects(width)948 }949950 y := 0951 inlineHeight := s.inlineFieldHeight(width)952 headerHeight := s.headerFieldHeight(width)953954 titleW := max((width-2)/2, 24)955 advertW := max(width-titleW-2, 24)956 row1 := exportSettingsRects{957 Title: exportRect{X: 0, Y: y, W: titleW, H: inlineHeight},958 Advert: exportRect{X: titleW + 2, Y: y, W: advertW, H: inlineHeight},959 }960 y += inlineHeight961 row1.Header = exportRect{X: 0, Y: y, W: width, H: headerHeight}962 y += headerHeight963964 volumeW := 14965 layoutW := 18966 jsonlW := 14967 seedW := max(width-volumeW-layoutW-jsonlW-6, 22)968 row1.Volume = exportRect{X: 0, Y: y, W: volumeW, H: inlineHeight}969 row1.Layout = exportRect{X: volumeW + 2, Y: y, W: layoutW, H: inlineHeight}970 row1.Seed = exportRect{X: volumeW + layoutW + 4, Y: y, W: seedW, H: inlineHeight}971 row1.JSONL = exportRect{X: volumeW + layoutW + seedW + 6, Y: y, W: jsonlW, H: inlineHeight}972 y += inlineHeight973974 row1.PDFPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}975 y += inlineHeight976977 if s.values.JSONLEnabled {978 row1.HasJSONL = true979 row1.JSONLPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}980 y += inlineHeight981 }982983 row1.TotalHeight = y984 return row1985}986987func (s *exportModel) stackedSettingsRects(width int) exportSettingsRects {988 y := 0989 inlineHeight := s.inlineFieldHeight(width)990 headerHeight := s.headerFieldHeight(width)991 rects := exportSettingsRects{992 Title: exportRect{X: 0, Y: y, W: width, H: inlineHeight},993 }994 y += inlineHeight995 rects.Advert = exportRect{X: 0, Y: y, W: width, H: inlineHeight}996 y += inlineHeight997 rects.Header = exportRect{X: 0, Y: y, W: width, H: headerHeight}998 y += headerHeight9991000 volumeW, layoutW, jsonlW := 14, 18, 141001 seedW := max(width-volumeW-layoutW-jsonlW-6, 16)1002 if seedW < 16 {1003 rects.Volume = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1004 y += inlineHeight1005 rects.Layout = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1006 y += inlineHeight1007 rects.Seed = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1008 y += inlineHeight1009 rects.JSONL = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1010 y += inlineHeight1011 } else {1012 rects.Volume = exportRect{X: 0, Y: y, W: volumeW, H: inlineHeight}1013 rects.Layout = exportRect{X: volumeW + 2, Y: y, W: layoutW, H: inlineHeight}1014 rects.Seed = exportRect{X: volumeW + layoutW + 4, Y: y, W: seedW, H: inlineHeight}1015 rects.JSONL = exportRect{X: volumeW + layoutW + seedW + 6, Y: y, W: jsonlW, H: inlineHeight}1016 y += inlineHeight1017 }10181019 rects.PDFPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1020 y += inlineHeight1021 if s.values.JSONLEnabled {1022 rects.HasJSONL = true1023 rects.JSONLPath = exportRect{X: 0, Y: y, W: width, H: inlineHeight}1024 y += inlineHeight1025 }1026 rects.TotalHeight = y1027 return rects1028}10291030func (s *exportModel) inlineFieldHeight(width int) int {1031 input := s.titleInput1032 return lipgloss.Height(s.renderInlineInputField("Title", &input, false, max(width, 14)))1033}10341035func (s *exportModel) headerFieldHeight(width int) int {1036 input := s.headerInput1037 return lipgloss.Height(s.renderTextareaField("Header", &input, false, max(width, 24)))1038}10391040func (s *exportModel) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {1041 if len(s.cards) == 0 || cardHeight <= 0 {1042 return nil1043 }1044 cols := s.cardColumns(width)1045 start := s.cardRowOffset * cols1046 end := min(len(s.cards), start+visibleRows*cols)1047 layouts := make([]exportCardRects, 0, end-start)1048 for i := start; i < end; i++ {1049 rel := i - start1050 row := rel / cols1051 col := rel % cols1052 rect := exportRect{1053 X: col * (cardWidth + exportCardGap),1054 Y: cardsY + row*(cardHeight+1),1055 W: cardWidth,1056 H: cardHeight,1057 }1058 layouts = append(layouts, exportCardRects{1059 Index: i,1060 Rect: rect,1061 Buckets: s.bucketRectsForCard(i, rect),1062 })1063 }1064 return layouts1065}10661067func (s *exportModel) bucketRectsForCard(index int, rect exportRect) [3]exportRect {1068 var rects [3]exportRect1069 card := s.cards[index]1070 selected := index == s.cardIndex1071 x := rect.X + 21072 y := rect.Y + 21073 for bucketIndex, name := range bucketNames {1074 part := s.renderDifficultyPair(card, selected, bucketIndex, name)1075 w := lipgloss.Width(part)1076 rects[bucketIndex] = exportRect{X: x, Y: y, W: w, H: 1}1077 x += w + 11078 }1079 return rects1080}10811082func (s *exportModel) renderSettings(width int) string {1083 if width < 72 {1084 return s.renderStackedSettings(width)1085 }10861087 titleW := max((width-2)/2, 24)1088 advertW := max(width-titleW-2, 24)1089 row1 := lipgloss.JoinHorizontal(lipgloss.Top,1090 s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, titleW),1091 " ",1092 s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, advertW),1093 )1094 rowHeader := s.renderTextareaField("Header", &s.headerInput, s.focus == exportFocusHeader, width)10951096 volumeW := 141097 layoutW := 181098 jsonlW := 141099 seedW := max(width-volumeW-layoutW-jsonlW-6, 22)1100 row2 := lipgloss.JoinHorizontal(lipgloss.Top,1101 s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, volumeW),1102 " ",1103 s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, layoutW),1104 " ",1105 s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, seedW),1106 " ",1107 s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, jsonlW),1108 )11091110 rows := []string{1111 row1,1112 rowHeader,1113 row2,1114 s.renderInlineInputField("PDF Path", &s.pdfPathInput, s.focus == exportFocusPDFPath, width),1115 }1116 if s.values.JSONLEnabled {1117 rows = append(rows, s.renderInlineInputField("JSONL Path", &s.jsonlPathInput, s.focus == exportFocusJSONLPath, width))1118 }11191120 return lipgloss.JoinVertical(lipgloss.Left, rows...)1121}11221123func (s *exportModel) renderStackedSettings(width int) string {1124 rows := []string{1125 s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, width),1126 s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, width),1127 s.renderTextareaField("Header", &s.headerInput, s.focus == exportFocusHeader, width),1128 }11291130 volumeW, layoutW, jsonlW := 14, 18, 141131 seedW := max(width-volumeW-layoutW-jsonlW-6, 16)1132 if seedW < 16 {1133 rows = append(rows,1134 s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, width),1135 s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, width),1136 s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, width),1137 s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, width),1138 )1139 } else {1140 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top,1141 s.renderInlineInputField("Volume", &s.volumeInput, s.focus == exportFocusVolume, volumeW),1142 " ",1143 s.renderInlineChoiceField("Layout", layoutLabel(s.values.SheetLayout), s.focus == exportFocusLayout, layoutW),1144 " ",1145 s.renderInlineInputField("Seed", &s.seedInput, s.focus == exportFocusSeed, seedW),1146 " ",1147 s.renderInlineChoiceField("JSONL", onOffLabel(s.values.JSONLEnabled), s.focus == exportFocusJSONLToggle, jsonlW),1148 ))1149 }11501151 rows = append(rows, s.renderInlineInputField("PDF Path", &s.pdfPathInput, s.focus == exportFocusPDFPath, width))1152 if s.values.JSONLEnabled {1153 rows = append(rows, s.renderInlineInputField("JSONL Path", &s.jsonlPathInput, s.focus == exportFocusJSONLPath, width))1154 }1155 return lipgloss.JoinVertical(lipgloss.Left, rows...)1156}11571158func (s *exportModel) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {1159 border := theme.Current().Border1160 if focused {1161 border = theme.Current().Accent1162 }11631164 innerWidth := max(width-4, 1)1165 input.SetWidth(innerWidth)1166 input.SetHeight(3)11671168 body := lipgloss.JoinVertical(1169 lipgloss.Left,1170 ui.DimItemStyle().Render(label),1171 input.View(),1172 )1173 return lipgloss.NewStyle().1174 Border(lipgloss.RoundedBorder()).1175 BorderForeground(border).1176 Padding(0, 1).1177 Width(width).1178 Render(body)1179}11801181func (s *exportModel) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {1182 border := theme.Current().Border1183 if focused {1184 border = theme.Current().Accent1185 }11861187 innerWidth := max(width-4, 1)1188 labelWidth := lipgloss.Width(label) + 11189 input.SetWidth(max(innerWidth-labelWidth, 1))11901191 body := lipgloss.JoinHorizontal(1192 lipgloss.Top,1193 ui.DimItemStyle().Render(label),1194 " ",1195 renderSingleLine(input.View(), innerWidth-labelWidth),1196 )1197 return lipgloss.NewStyle().1198 Border(lipgloss.RoundedBorder()).1199 BorderForeground(border).1200 Padding(0, 1).1201 Width(width).1202 Height(1).1203 Render(body)1204}12051206func (s *exportModel) renderInlineChoiceField(label, value string, focused bool, width int) string {1207 border := theme.Current().Border1208 if focused {1209 border = theme.Current().Accent1210 }12111212 innerWidth := max(width-4, 1)1213 labelWidth := lipgloss.Width(label) + 11214 valueStyle := lipgloss.NewStyle().Foreground(theme.Current().FG)1215 if focused {1216 valueStyle = valueStyle.Foreground(theme.Current().Accent).Bold(true)1217 }12181219 body := lipgloss.JoinHorizontal(1220 lipgloss.Top,1221 ui.DimItemStyle().Render(label),1222 " ",1223 renderSingleLine(valueStyle.Render(value), innerWidth-labelWidth),1224 )1225 return lipgloss.NewStyle().1226 Border(lipgloss.RoundedBorder()).1227 BorderForeground(border).1228 Padding(0, 1).1229 Width(width).1230 Height(1).1231 Render(body)1232}12331234func renderSingleLine(value string, width int) string {1235 if width <= 0 {1236 return ""1237 }1238 return lipgloss.NewStyle().1239 Width(width).1240 MaxWidth(width).1241 Height(1).1242 MaxHeight(1).1243 Render(value)1244}12451246func renderChoiceChip(label string, focused bool) string {1247 style := lipgloss.NewStyle().1248 Bold(true).1249 Padding(0, 1).1250 Foreground(theme.Current().FG).1251 Border(lipgloss.NormalBorder()).1252 BorderForeground(theme.Current().Border)1253 if focused {1254 style = style.Foreground(theme.TextOnBG(theme.Current().Accent)).Background(theme.Current().Accent).BorderForeground(theme.Current().Accent)1255 }1256 return style.Render(label)1257}12581259func layoutLabel(layout string) string {1260 switch layout {1261 case "duplex-booklet":1262 return "Booklet"1263 default:1264 return "Half Letter"1265 }1266}12671268func onOffLabel(enabled bool) string {1269 if enabled {1270 return "On"1271 }1272 return "Off"1273}12741275func (s *exportModel) renderSummary(width int) string {1276 total := 01277 for _, card := range s.cards {1278 for _, count := range card.Buckets {1279 total += count1280 }1281 }12821283 selected := "none"1284 seedHint := ""1285 if len(s.cards) > 0 {1286 card := s.cards[s.cardIndex]1287 selected = fmt.Sprintf("%s / %s = %d", card.GameType, bucketNames[s.bucketIndex], card.Buckets[s.bucketIndex])1288 if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(s.bucketIndex) {1289 seedHint = " selected bucket cannot be seeded"1290 }1291 }12921293 button := renderChoiceChip("Export", s.focus == exportFocusSubmit)1294 totalText := ui.PanelTitle().Render(fmt.Sprintf("Total: %d", total))1295 detailWidth := max(width-lipgloss.Width(totalText)-lipgloss.Width(button)-4, 12)1296 detailText := ui.DimItemStyle().Render(truncateLabel("Selected: "+selected+seedHint, detailWidth))1297 detail := lipgloss.NewStyle().Width(detailWidth).MaxWidth(detailWidth).Render(detailText)1298 return lipgloss.JoinHorizontal(lipgloss.Top, totalText, " ", detail, " ", button)1299}13001301var bucketNames = [3]string{"Easy", "Medium", "Hard"}13021303func (s *exportModel) renderCards(width, height int) string {1304 if len(s.cards) == 0 {1305 return ui.DimItemStyle().Render("No exportable games found.")1306 }13071308 cols := s.cardColumns(width)1309 cardWidth := exportCardWidth(width, cols)1310 start := s.cardRowOffset * cols1311 end := min(len(s.cards), start+s.visibleCardRows(width, height)*cols)13121313 rows := []string{}1314 for i := start; i < end; i += cols {1315 rowEnd := min(i+cols, end)1316 parts := make([]string, 0, rowEnd-i)1317 for idx := i; idx < rowEnd; idx++ {1318 parts = append(parts, s.renderCard(idx, cardWidth))1319 }1320 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, joinCardParts(parts)...))1321 }13221323 footer := ""1324 totalRows := max((len(s.cards)+cols-1)/cols, 1)1325 if totalRows > s.visibleCardRows(width, height) {1326 footer = ui.DimItemStyle().Render(fmt.Sprintf("rows %d-%d of %d", s.cardRowOffset+1, min(s.cardRowOffset+s.visibleCardRows(width, height), totalRows), totalRows))1327 }13281329 content := strings.Join(rows, "\n")1330 if footer != "" {1331 content = lipgloss.JoinVertical(lipgloss.Left, content, footer)1332 }1333 return content1334}13351336func (s *exportModel) renderCard(index, width int) string {1337 card := s.cards[index]1338 selected := index == s.cardIndex13391340 border := theme.Current().Border1341 if selected {1342 border = theme.Current().Accent1343 }13441345 titleStyle := ui.PanelTitle()1346 if selected {1347 titleStyle = ui.SelectedItemStyle()1348 }13491350 bucketParts := make([]string, 0, 3)1351 for bucketIndex, name := range bucketNames {1352 bucketParts = append(bucketParts, s.renderDifficultyPair(card, selected, bucketIndex, name))1353 }13541355 total := card.Buckets[0] + card.Buckets[1] + card.Buckets[2]1356 titleWidth := max(width-10, 6)1357 title := truncateLabel(card.GameType, titleWidth)1358 titleLine := lipgloss.JoinHorizontal(1359 lipgloss.Top,1360 titleStyle.Width(titleWidth).Render(title),1361 " ",1362 ui.DimItemStyle().Render(fmt.Sprintf("%d", total)),1363 )13641365 body := lipgloss.JoinVertical(1366 lipgloss.Left,1367 titleLine,1368 lipgloss.JoinHorizontal(lipgloss.Top, bucketParts[0], " ", bucketParts[1], " ", bucketParts[2]),1369 )13701371 return lipgloss.NewStyle().1372 Border(lipgloss.RoundedBorder()).1373 BorderForeground(border).1374 Padding(0, 1).1375 Width(width).1376 Render(body)1377}13781379func (s *exportModel) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {1380 palette := theme.Current()1381 bg, fg := exportDifficultyColors(bucketIndex, palette)1382 pairStyle := lipgloss.NewStyle().1383 Foreground(fg).1384 Background(bg).1385 Bold(true).1386 Padding(0, 0)1387 if strings.TrimSpace(s.values.Seed) != "" && !card.bucketSeeded(bucketIndex) {1388 pairStyle = pairStyle.Foreground(palette.TextDim).Background(theme.Blend(palette.BG, bg, 0.35))1389 }13901391 pair := pairStyle.Render(fmt.Sprintf("%s%d", string(bucketName[0]), card.Buckets[bucketIndex]))1392 if cardSelected && s.focus == exportFocusCards && bucketIndex == s.bucketIndex {1393 return puzzlegame.CursorStyle().Render(puzzlegame.CursorLeft) + pair + puzzlegame.CursorStyle().Render(puzzlegame.CursorRight)1394 }1395 return pair1396}13971398func (s *exportModel) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, bool) {1399 mouse := msg.Mouse()1400 content := s.view(contentWidth, contentHeight)1401 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)1402 localX := mouse.X - contentX1403 localY := mouse.Y - contentY1404 if localX < 0 || localY < 0 {1405 return nil, false1406 }14071408 metrics := s.contentMetrics(contentWidth, contentHeight)1409 if cmd, handled := s.handleSettingsClick(localX, localY, metrics); handled {1410 return cmd, true1411 }1412 if metrics.SummaryButton.contains(localX, localY) {1413 s.focus = exportFocusSubmit1414 return tea.Batch(s.applyFocus(), exportSubmitCmd), true1415 }1416 if cmd, handled := s.handleCardClick(localX, localY, metrics); handled {1417 return cmd, true1418 }1419 return nil, false1420}14211422func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (panelX, panelY, contentX, contentY int) {1423 panel := ui.Panel("Export", content, exportFooterHint)1424 panelWidth := lipgloss.Width(panel)1425 panelHeight := lipgloss.Height(panel)1426 panelX = max((screenWidth-panelWidth)/2, 0)1427 panelY = max((screenHeight-panelHeight)/2, 0)14281429 frame := ui.PanelFrame()1430 contentX = panelX + frame.GetHorizontalFrameSize()/21431 contentY = panelY + 1 + 2 + 21432 return panelX, panelY, contentX, contentY1433}14341435func (s *exportModel) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {1436 mouse := msg.Mouse()1437 content := s.view(contentWidth, contentHeight)1438 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)1439 localX := mouse.X - contentX1440 localY := mouse.Y - contentY1441 if localX < 0 || localY < 0 {1442 return false1443 }1444 metrics := s.contentMetrics(contentWidth, contentHeight)1445 if !metrics.CardsRect.contains(localX, localY) {1446 return false1447 }1448 totalRows := max((len(s.cards)+metrics.CardColumns-1)/metrics.CardColumns, 1)1449 if totalRows <= metrics.VisibleRows {1450 return false1451 }1452 switch mouse.Button {1453 case tea.MouseWheelDown:1454 s.cardRowOffset = min(s.cardRowOffset+1, totalRows-metrics.VisibleRows)1455 case tea.MouseWheelUp:1456 s.cardRowOffset = max(s.cardRowOffset-1, 0)1457 default:1458 return false1459 }1460 s.clampSelectionToVisibleRows(metrics.CardColumns, metrics.VisibleRows)1461 return true1462}14631464func (s *exportModel) clampSelectionToVisibleRows(cols, visibleRows int) {1465 if len(s.cards) == 0 || cols <= 0 || visibleRows <= 0 {1466 return1467 }1468 row := s.cardIndex / cols1469 col := s.cardIndex % cols1470 if row < s.cardRowOffset {1471 row = s.cardRowOffset1472 }1473 maxVisibleRow := s.cardRowOffset + visibleRows - 11474 if row > maxVisibleRow {1475 row = maxVisibleRow1476 }1477 index := row*cols + col1478 if index >= len(s.cards) {1479 index = len(s.cards) - 11480 }1481 s.cardIndex = max(index, 0)1482}14831484func (s *exportModel) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1485 settings := metrics.Settings1486 switch {1487 case settings.Title.contains(localX, localY):1488 return s.focusTextInput(exportFocusTitle, &s.titleInput, settings.Title, "Title", localX), true1489 case settings.Advert.contains(localX, localY):1490 return s.focusTextInput(exportFocusAdvert, &s.advertInput, settings.Advert, "Advert", localX), true1491 case settings.Header.contains(localX, localY):1492 return s.focusTextarea(settings.Header, localX, localY), true1493 case settings.Volume.contains(localX, localY):1494 return s.focusTextInput(exportFocusVolume, &s.volumeInput, settings.Volume, "Volume", localX), true1495 case settings.Layout.contains(localX, localY):1496 s.focus = exportFocusLayout1497 s.cycleLayout(1)1498 return s.applyFocus(), true1499 case settings.Seed.contains(localX, localY):1500 return s.focusTextInput(exportFocusSeed, &s.seedInput, settings.Seed, "Seed", localX), true1501 case settings.JSONL.contains(localX, localY):1502 s.focus = exportFocusJSONLToggle1503 s.toggleJSONL()1504 return s.applyFocus(), true1505 case settings.PDFPath.contains(localX, localY):1506 return s.focusTextInput(exportFocusPDFPath, &s.pdfPathInput, settings.PDFPath, "PDF Path", localX), true1507 case settings.HasJSONL && settings.JSONLPath.contains(localX, localY):1508 return s.focusTextInput(exportFocusJSONLPath, &s.jsonlPathInput, settings.JSONLPath, "JSONL Path", localX), true1509 default:1510 return nil, false1511 }1512}15131514func (s *exportModel) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {1515 s.focus = focus1516 labelWidth := lipgloss.Width(label) + 11517 cursorPos := max(localX-rect.X-2-labelWidth, 0)1518 input.SetCursor(cursorPos)1519 return s.applyFocus()1520}15211522func (s *exportModel) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {1523 s.focus = exportFocusHeader1524 line := max(localY-rect.Y-2, 0)1525 col := max(localX-rect.X-2, 0)1526 maxLine := max(s.headerInput.LineCount()-1, 0)1527 line = min(line, maxLine)1528 s.headerInput.MoveToBegin()1529 for i := 0; i < line; i++ {1530 s.headerInput.CursorDown()1531 }1532 s.headerInput.SetCursorColumn(col)1533 return s.applyFocus()1534}15351536func (s *exportModel) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1537 if len(s.cards) == 0 {1538 return nil, false1539 }1540 for _, layout := range metrics.CardRects {1541 if !layout.Rect.contains(localX, localY) {1542 continue1543 }1544 for bucketIndex, bucket := range layout.Buckets {1545 if bucket.contains(localX, localY) {1546 return s.selectCardBucket(layout.Index, bucketIndex, metrics), true1547 }1548 }1549 return s.selectCardBucket(layout.Index, s.bucketIndex, metrics), true1550 }1551 return nil, false1552}15531554func (s *exportModel) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {1555 s.focus = exportFocusCards1556 s.cardIndex = cardIndex1557 s.bucketIndex = min(max(bucketIndex, 0), len(bucketNames)-1)1558 s.ensureCardSelectionVisible(metrics.Width, metrics.Height)1559 return s.applyFocus()1560}15611562type exportContentMetrics struct {1563 Width int1564 Height int1565 SettingsHeight int1566 SummaryHeight int1567 Settings exportSettingsRects1568 Summary exportRect1569 SummaryButton exportRect1570 CardsRect exportRect1571 CardsY int1572 CardWidth int1573 CardHeight int1574 CardColumns int1575 VisibleRows int1576 CardRects []exportCardRects1577}15781579func exportDifficultyColors(bucketIndex int, palette theme.Palette) (bg, fg color.Color) {1580 switch bucketIndex {1581 case 0:1582 bg = palette.ErrorBG1583 case 1:1584 bg = palette.SuccessBG1585 default:1586 bg = theme.Blend(palette.BG, palette.Secondary, 0.35)1587 }1588 fg = theme.TextOnBG(bg)1589 return bg, fg1590}15911592func exportCardWidth(width, cols int) int {1593 if cols <= 0 {1594 return width1595 }1596 return max((width-(cols-1)*exportCardGap)/cols, exportCardMinWidth)1597}15981599func joinCardParts(parts []string) []string {1600 if len(parts) == 0 {1601 return nil1602 }1603 joined := make([]string, 0, len(parts)*2-1)1604 for i, part := range parts {1605 if i > 0 {1606 joined = append(joined, strings.Repeat(" ", exportCardGap))1607 }1608 joined = append(joined, part)1609 }1610 return joined1611}16121613func truncateLabel(s string, width int) string {1614 if width <= 0 {1615 return ""1616 }1617 runes := []rune(s)1618 if len(runes) <= width {1619 return s1620 }1621 if width == 1 {1622 return string(runes[:1])1623 }1624 return string(runes[:width-1]) + "…"1625}16261627func (s exportModel) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {1628 sync := s1629 sync.syncValuesFromInputs()16301631 spec := sync.values.toExactSpec()1632 spec.Counts = expandCardCounts(sync.cards)1633 if !sync.values.JSONLEnabled {1634 spec.JSONLOutputPath = ""1635 }16361637 cfg := config.ExportConfig{1638 Title: spec.Title,1639 Header: spec.Header,1640 Advert: spec.Advert,1641 Volume: spec.Volume,1642 SheetLayout: spec.SheetLayout,1643 Seed: spec.Seed,1644 PDFOutputPath: spec.PDFOutputPath,1645 JSONLEnabled: sync.values.JSONLEnabled,1646 JSONLOutputPath: sync.values.JSONLOutputPath,1647 Counts: spec.Counts,1648 }1649 return spec, cfg1650}16511652func (s *exportModel) syncValuesFromInputs() {1653 s.values.Title = s.titleInput.Value()1654 s.values.Header = s.headerInput.Value()1655 s.values.Advert = s.advertInput.Value()1656 s.values.Volume = s.volumeInput.Value()1657 s.values.Seed = s.seedInput.Value()1658 s.values.PDFOutputPath = s.pdfPathInput.Value()1659 s.values.JSONLOutputPath = s.jsonlPathInput.Value()1660}16611662func (v exportFormValues) toExactSpec() packexport.Spec {1663 volume, err := strconv.Atoi(strings.TrimSpace(v.Volume))1664 if err != nil {1665 volume = -11666 }16671668 spec := packexport.Spec{1669 Title: strings.TrimSpace(v.Title),1670 Header: strings.TrimSpace(v.Header),1671 Advert: strings.TrimSpace(v.Advert),1672 Volume: volume,1673 SheetLayout: strings.TrimSpace(v.SheetLayout),1674 Seed: strings.TrimSpace(v.Seed),1675 PDFOutputPath: strings.TrimSpace(v.PDFOutputPath),1676 Counts: map[puzzle.GameID]map[puzzle.ModeID]int{},1677 }1678 if v.JSONLEnabled {1679 spec.JSONLOutputPath = strings.TrimSpace(v.JSONLOutputPath)1680 }1681 return spec1682}16831684func expandCardCounts(cards []exportGameCard) map[puzzle.GameID]map[puzzle.ModeID]int {1685 counts := make(map[puzzle.GameID]map[puzzle.ModeID]int, len(cards))1686 for _, card := range cards {1687 if _, ok := counts[card.GameID]; !ok {1688 counts[card.GameID] = make(map[puzzle.ModeID]int)1689 }1690 for bucketIndex, total := range card.Buckets {1691 modes := card.BucketModes[bucketIndex]1692 if len(modes) == 0 {1693 continue1694 }1695 base := total / len(modes)1696 remainder := total % len(modes)1697 for i, mode := range modes {1698 count := base1699 if i < remainder {1700 count++1701 }1702 counts[mode.GameID][mode.ModeID] = count1703 }1704 }1705 }1706 return counts1707}17081709// runeIdxBeforeExt returns the rune index just before the file extension so1710// that focusing a path field via tab lands the cursor there. This lets users1711// type a new base-name suffix (e.g. "2") without overwriting ".pdf"/".jsonl".1712func runeIdxBeforeExt(path string) int {1713 ext := filepath.Ext(path)1714 runes := []rune(path)1715 extRunes := len([]rune(ext))1716 if extRunes == 0 || extRunes >= len(runes) {1717 return len(runes)1718 }1719 return len(runes) - extRunes1720}