clone

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

main
puzzletea / app / screen_create.go
screen_create.go 18.3K
1package app23import (4 "strconv"5 "strings"67 sessionflow "github.com/FelineStateMachine/puzzletea/session"8 "github.com/FelineStateMachine/puzzletea/theme"9 "github.com/FelineStateMachine/puzzletea/ui"1011 "charm.land/bubbles/v2/key"12 tea "charm.land/bubbletea/v2"13 "charm.land/lipgloss/v2"14)1516type createScreen struct {17 width int18 height int19 create createState20}2122const createFooterHint = "↑/↓ navigate • enter expand/select/generate • space select • tab field • click select/generate • esc back"2324type createRect struct {25 X int26 Y int27 W int28 H int29}3031func (r createRect) contains(x, y int) bool {32 return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H33}3435type createSettingsRects struct {36 Elo createRect37 Seed createRect38 TotalHeight int39}4041type createContentMetrics struct {42 Width int43 Height int44 Settings createSettingsRects45 Tree createRect46 TreeStart int47 TreeEnd int48 Pool createRect49 Summary createRect50 SummaryButton createRect51}5253func (s createScreen) Resize(width, height int) screenModel {54 s.width = width55 s.height = height56 contentWidth, _ := createContentBounds(width, height)57 _, sideWidth := createLayoutWidths(contentWidth)58 s.create.seedInput.SetWidth(max(10, sideWidth-8))59 return s60}6162func (s createScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {63 if clickMsg, ok := msg.(tea.MouseClickMsg); ok {64 contentWidth, contentHeight := createContentBounds(s.width, s.height)65 if next, cmd, action, handled := s.handleMouseClick(clickMsg, contentWidth, contentHeight); handled {66 return next, cmd, action67 }68 }6970 keyMsg, ok := msg.(tea.KeyPressMsg)71 if ok {72 switch {73 case key.Matches(keyMsg, rootKeys.Enter):74 return s.handleEnter()75 case key.Matches(keyMsg, rootKeys.Escape):76 return s, nil, createBackAction{create: s.create}77 case keyMsg.String() == "tab":78 s.create.focus = s.nextFocus(1)79 return s.syncFocus()80 case keyMsg.String() == "shift+tab":81 s.create.focus = s.nextFocus(-1)82 return s.syncFocus()83 case keyMsg.String() == "up" || keyMsg.String() == "k":84 if s.create.focus == createFocusTree {85 s.moveCursor(-1)86 return s, nil, nil87 }88 case keyMsg.String() == "down" || keyMsg.String() == "j":89 if s.create.focus == createFocusTree {90 s.moveCursor(1)91 return s, nil, nil92 }93 case keyMsg.String() == " " || keyMsg.String() == "space":94 if s.create.focus == createFocusTree {95 s.toggleCurrentTreeNode()96 return s, nil, nil97 }98 }99 }100101 return s.updateFocusedInput(msg)102}103104func (s createScreen) handleMouseClick(msg tea.MouseClickMsg, contentWidth, contentHeight int) (screenModel, tea.Cmd, screenAction, bool) {105 mouse := msg.Mouse()106 content := createBody(s.create, s.width, s.height)107 _, _, contentX, contentY := createPanelContentOrigin(s.width, s.height, content)108 localX := mouse.X - contentX109 localY := mouse.Y - contentY110 if localX < 0 || localY < 0 {111 return s, nil, nil, false112 }113114 metrics := createMetrics(s.create, contentWidth, contentHeight)115 if metrics.Settings.Elo.contains(localX, localY) {116 s.create.focus = createFocusElo117 s.create.eloInput.SetCursor(createFieldCursor(localX, metrics.Settings.Elo, "Elo"))118 next, cmd, action := s.syncFocus()119 return next, cmd, action, true120 }121 if metrics.Settings.Seed.contains(localX, localY) {122 if !s.create.seedEnabled() {123 return s, nil, nil, true124 }125 s.create.focus = createFocusSeed126 s.create.seedInput.SetCursor(createFieldCursor(localX, metrics.Settings.Seed, "Seed"))127 next, cmd, action := s.syncFocus()128 return next, cmd, action, true129 }130 if metrics.SummaryButton.contains(localX, localY) {131 s.create.focus = createFocusGenerate132 next, cmd, action := s.syncFocus()133 if s.create.selectedCount() == 0 {134 return next, cmd, action, true135 }136 return next, cmd, createGenerateAction{create: s.create}, true137 }138 if index, ok := createTreeIndexAt(localX, localY, metrics, s.create); ok {139 s.create.focus = createFocusTree140 s.create.cursor = index141 next, cmd, action := s.syncFocus()142 nextScreen := next.(createScreen)143 return nextScreen.handleEnterWithHandled(cmd, action)144 }145 return s, nil, nil, false146}147148func (s createScreen) handleEnterWithHandled(cmd tea.Cmd, action screenAction) (screenModel, tea.Cmd, screenAction, bool) {149 next, enterCmd, enterAction := s.handleEnter()150 return next, tea.Batch(cmd, enterCmd), firstScreenAction(action, enterAction), true151}152153func firstScreenAction(actions ...screenAction) screenAction {154 for _, action := range actions {155 if action != nil {156 return action157 }158 }159 return nil160}161162func createFieldCursor(localX int, rect createRect, label string) int {163 labelWidth := lipgloss.Width(label) + 1164 return max(localX-rect.X-2-labelWidth, 0)165}166167func createPanelContentOrigin(screenWidth, screenHeight int, content string) (panelX, panelY, contentX, contentY int) {168 panel := ui.Panel("Create", content, createFooterHint)169 panelWidth := lipgloss.Width(panel)170 panelHeight := lipgloss.Height(panel)171 panelX = max((screenWidth-panelWidth)/2, 0)172 panelY = max((screenHeight-panelHeight)/2, 0)173174 frame := ui.PanelFrame()175 contentX = panelX + frame.GetHorizontalFrameSize()/2176 contentY = panelY + 1 + 2 + 2177 return panelX, panelY, contentX, contentY178}179180func (s createScreen) handleEnter() (screenModel, tea.Cmd, screenAction) {181 if s.create.focus == createFocusGenerate {182 if s.create.selectedCount() == 0 {183 return s, nil, nil184 }185 return s, nil, createGenerateAction{create: s.create}186 }187 if s.create.focus != createFocusTree {188 return s, nil, nil189 }190191 visible := s.create.visibleNodes()192 if s.create.cursor < 0 || s.create.cursor >= len(visible) {193 return s, nil, nil194 }195 node := visible[s.create.cursor].node196 if node.leaf == nil {197 s.create.expanded[node.id] = !s.create.expanded[node.id]198 return s, nil, nil199 }200 s.toggleLeaf(node)201 return s, nil, nil202}203204func (s createScreen) nextFocus(delta int) createFocus {205 focuses := []createFocus{createFocusTree, createFocusElo, createFocusGenerate}206 if s.create.seedEnabled() {207 focuses = []createFocus{createFocusTree, createFocusElo, createFocusSeed, createFocusGenerate}208 }209210 current := 0211 for i, focus := range focuses {212 if focus == s.create.focus {213 current = i214 break215 }216 }217 next := current + delta218 for next < 0 {219 next += len(focuses)220 }221 return focuses[next%len(focuses)]222}223224func (s createScreen) syncFocus() (screenModel, tea.Cmd, screenAction) {225 s.create.eloInput.Blur()226 s.create.seedInput.Blur()227 switch s.create.focus {228 case createFocusElo:229 return s, s.create.eloInput.Focus(), nil230 case createFocusSeed:231 if s.create.seedEnabled() {232 return s, s.create.seedInput.Focus(), nil233 }234 s.create.focus = createFocusTree235 return s, nil, nil236 case createFocusGenerate:237 return s, nil, nil238 default:239 return s, nil, nil240 }241}242243func (s createScreen) updateFocusedInput(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {244 var cmd tea.Cmd245 switch s.create.focus {246 case createFocusElo:247 s.create.eloInput, cmd = s.create.eloInput.Update(msg)248 case createFocusSeed:249 if s.create.seedEnabled() {250 s.create.seedInput, cmd = s.create.seedInput.Update(msg)251 }252 }253 return s, cmd, nil254}255256func (s *createScreen) moveCursor(delta int) {257 visible := s.create.visibleNodes()258 if len(visible) == 0 {259 s.create.cursor = 0260 return261 }262 s.create.cursor += delta263 if s.create.cursor < 0 {264 s.create.cursor = len(visible) - 1265 }266 if s.create.cursor >= len(visible) {267 s.create.cursor = 0268 }269}270271func (s *createScreen) toggleCurrentTreeNode() {272 visible := s.create.visibleNodes()273 if s.create.cursor < 0 || s.create.cursor >= len(visible) {274 return275 }276 node := visible[s.create.cursor].node277 if node.leaf == nil {278 s.create.toggleDescendantLeaves(node)279 s.afterCreateSelectionChanged()280 return281 }282 s.toggleLeaf(node)283}284285func (s *createScreen) toggleLeaf(node createTreeNode) {286 if node.leaf == nil {287 return288 }289 s.create.checked[node.leaf.id] = !s.create.checked[node.leaf.id]290 s.afterCreateSelectionChanged()291}292293func (s *createScreen) afterCreateSelectionChanged() {294 if !s.create.seedEnabled() {295 s.create.seedInput.Blur()296 if s.create.focus == createFocusSeed {297 s.create.focus = createFocusTree298 }299 }300}301302func (s createScreen) View(notice noticeState) string {303 return renderPanelView(304 s.width,305 s.height,306 notice,307 "Create",308 createBody(s.create, s.width, s.height),309 createFooterHint,310 )311}312313func createBody(state createState, width, height int) string {314 contentWidth, contentHeight := createContentBounds(width, height)315 treeWidth, sideWidth := createLayoutWidths(contentWidth)316 settings := createSettingsPanel(state, contentWidth)317 tree := createTreePanel(state, treeWidth, contentHeight)318 sidebar := createPoolPanel(state, sideWidth, contentHeight)319 summary := createSummaryPanel(state, contentWidth)320321 var middle string322 if contentWidth < 88 {323 middle = lipgloss.JoinVertical(lipgloss.Left, tree, sidebar)324 } else {325 middle = lipgloss.JoinHorizontal(lipgloss.Top, tree, " ", sidebar)326 }327 return lipgloss.JoinVertical(lipgloss.Left, settings, middle, summary)328}329330func createContentBounds(width, height int) (contentWidth, contentHeight int) {331 contentWidth = max(width-14, 24)332 contentHeight = max(height-14, 10)333 return contentWidth, contentHeight334}335336func createLayoutWidths(contentWidth int) (treeWidth, sideWidth int) {337 sideWidth = min(42, max(30, contentWidth/3))338 treeWidth = max(24, contentWidth-sideWidth-3)339 return treeWidth, sideWidth340}341342func createMetrics(state createState, width, height int) createContentMetrics {343 settings := createSettingsRectsForWidth(state, width)344 treeWidth, sideWidth := createLayoutWidths(width)345 tree := createTreePanel(state, treeWidth, height)346 pool := createPoolPanel(state, sideWidth, height)347 summary := createSummaryPanel(state, width)348 treeHeight := lipgloss.Height(tree)349 poolHeight := lipgloss.Height(pool)350 summaryHeight := lipgloss.Height(summary)351352 treeY := settings.TotalHeight353 poolX := 0354 poolY := treeY + treeHeight355 if width >= 88 {356 poolX = treeWidth + 3357 poolY = treeY358 }359 summaryY := settings.TotalHeight + treeHeight + poolHeight360 if width >= 88 {361 summaryY = settings.TotalHeight + max(treeHeight, poolHeight)362 }363364 button := renderCreateGenerateChip(state)365 buttonWidth := lipgloss.Width(button)366 start, end := createVisibleWindow(state, height)367 return createContentMetrics{368 Width: width,369 Height: height,370 Settings: settings,371 Tree: createRect{X: 0, Y: treeY, W: treeWidth, H: treeHeight},372 TreeStart: start,373 TreeEnd: end,374 Pool: createRect{X: poolX, Y: poolY, W: sideWidth, H: poolHeight},375 Summary: createRect{X: 0, Y: summaryY, W: width, H: summaryHeight},376 SummaryButton: createRect{377 X: max(width-buttonWidth, 0),378 Y: summaryY,379 W: buttonWidth,380 H: summaryHeight,381 },382 }383}384385func createSettingsRectsForWidth(state createState, width int) createSettingsRects {386 inlineHeight := lipgloss.Height(createInlineField("Elo", state.eloInput.View(), state.focus == createFocusElo, max(width, 18)))387 if width < 52 {388 return createSettingsRects{389 Elo: createRect{X: 0, Y: 0, W: width, H: inlineHeight},390 Seed: createRect{X: 0, Y: inlineHeight, W: width, H: inlineHeight},391 TotalHeight: inlineHeight * 2,392 }393 }394395 eloWidth := 18396 seedWidth := max(width-eloWidth-2, 24)397 return createSettingsRects{398 Elo: createRect{X: 0, Y: 0, W: eloWidth, H: inlineHeight},399 Seed: createRect{X: eloWidth + 2, Y: 0, W: seedWidth, H: inlineHeight},400 TotalHeight: inlineHeight,401 }402}403404func createVisibleWindow(state createState, height int) (start, end int) {405 visible := state.visibleNodes()406 if len(visible) == 0 {407 return 0, 0408 }409 maxRows := max(8, height-9)410 if len(visible) <= maxRows {411 return 0, len(visible)412 }413 start = min(max(0, state.cursor-maxRows+1), len(visible)-maxRows)414 return start, start + maxRows415}416417func createTreeIndexAt(localX, localY int, metrics createContentMetrics, state createState) (int, bool) {418 if !metrics.Tree.contains(localX, localY) {419 return 0, false420 }421 row := localY - metrics.Tree.Y - 2422 if row < 0 || row >= metrics.TreeEnd-metrics.TreeStart {423 return 0, false424 }425 visible := state.visibleNodes()426 if metrics.TreeStart > 0 && row == 0 {427 return 0, false428 }429 if metrics.TreeEnd < len(visible) && row == metrics.TreeEnd-metrics.TreeStart-1 {430 return 0, false431 }432 index := metrics.TreeStart + row433 if index < 0 || index >= len(visible) {434 return 0, false435 }436 return index, true437}438439func createTreePanel(state createState, width, height int) string {440 lines := make([]string, 0)441 for i, item := range state.visibleNodes() {442 lines = append(lines, renderCreateTreeLine(state, item, i == state.cursor))443 }444 if len(lines) == 0 {445 lines = append(lines, "No Elo-capable games available.")446 }447448 start, end := createVisibleWindow(state, height)449 if len(state.visibleNodes()) > 0 && len(lines) > end-start {450 lines = lines[start:end]451 if start > 0 {452 lines[0] = ui.DimItemStyle().Render("...")453 }454 if end < len(state.visibleNodes()) {455 lines[len(lines)-1] = ui.DimItemStyle().Render("...")456 }457 }458459 return createSection("Puzzle Pool", strings.Join(lines, "\n"), state.focus == createFocusTree, width)460}461462func createSettingsPanel(state createState, width int) string {463 seed := state.seedInput.View()464 if !state.seedEnabled() {465 seed = "disabled for multi-select"466 }467468 if width < 52 {469 return lipgloss.JoinVertical(lipgloss.Left,470 createInlineField("Elo", state.eloInput.View(), state.focus == createFocusElo, width),471 createInlineTextField("Seed", seed, state.focus == createFocusSeed && state.seedEnabled(), !state.seedEnabled(), width),472 )473 }474475 eloWidth := 18476 seedWidth := max(width-eloWidth-2, 24)477 return lipgloss.JoinHorizontal(lipgloss.Top,478 createInlineField("Elo", state.eloInput.View(), state.focus == createFocusElo, eloWidth),479 " ",480 createInlineTextField("Seed", seed, state.focus == createFocusSeed && state.seedEnabled(), !state.seedEnabled(), seedWidth),481 )482}483484func createPoolPanel(state createState, width, height int) string {485 body := lipgloss.JoinVertical(486 lipgloss.Left,487 ui.DimItemStyle().Render(strconv.Itoa(state.selectedCount())+" selected"),488 renderSelectedLeafSummary(state, width-4, max(3, height-7)),489 )490 return createSection("Selected", body, false, width)491}492493func createSummaryPanel(state createState, width int) string {494 totalText := ui.PanelTitle().Render("Total: " + strconv.Itoa(state.selectedCount()))495 selected := selectedCreateDetail(state)496 button := renderCreateGenerateChip(state)497 detailWidth := max(width-lipgloss.Width(totalText)-lipgloss.Width(button)-4, 12)498 detail := lipgloss.NewStyle().499 Width(detailWidth).500 MaxWidth(detailWidth).501 Render(ui.DimItemStyle().Render(truncatePlainCreateLine(selected, detailWidth)))502 return lipgloss.JoinHorizontal(lipgloss.Top, totalText, " ", detail, " ", button)503}504505func renderSelectedLeafSummary(state createState, width, maxRows int) string {506 leaves := state.selectedLeaves()507 if len(leaves) == 0 {508 return ui.DimItemStyle().Render("No leaves selected")509 }510511 limit := min(len(leaves), maxRows)512 lines := make([]string, 0, limit+1)513 for _, leaf := range leaves[:limit] {514 lines = append(lines, ui.DimItemStyle().Render(truncatePlainCreateLine(leaf.gameType+" / "+leaf.title, width)))515 }516 if len(leaves) > limit {517 lines = append(lines, ui.DimItemStyle().Render("+"+strconv.Itoa(len(leaves)-limit)+" more"))518 }519 return strings.Join(lines, "\n")520}521522func selectedCreateDetail(state createState) string {523 leaves := state.selectedLeaves()524 if len(leaves) == 0 {525 return "Selected: none"526 }527 if len(leaves) == 1 {528 return "Selected: " + leaves[0].gameType + " / " + leaves[0].title529 }530 return "Selected: random from checked pool"531}532533func renderCreateGenerateChip(state createState) string {534 if state.selectedCount() == 0 {535 return ui.DimItemStyle().Render(renderChoiceChip("Generate", false))536 }537 return renderChoiceChip("Generate", state.focus == createFocusGenerate)538}539540func renderCreateTreeLine(state createState, item createVisibleNode, cursor bool) string {541 node := item.node542 prefix := strings.Repeat(" ", item.depth)543 cursorText := " "544 if cursor && state.focus == createFocusTree {545 cursorText = ui.CursorStyle().Render("> ")546 }547 if node.leaf == nil {548 selected, total := state.descendantLeafCounts(node)549 marker := "[ ] "550 if total > 0 && selected == total {551 marker = "[x] "552 } else if selected > 0 {553 marker = "[-] "554 }555 count := ""556 if total > 1 {557 count = " " + ui.DimItemStyle().Render(strconv.Itoa(selected)+"/"+strconv.Itoa(total))558 }559 return cursorText + prefix + marker + node.title + count560 }561562 box := "[ ] "563 if state.checked[node.leaf.id] {564 box = "[x] "565 }566 return cursorText + prefix + box + node.title567}568569func createInlineField(label, value string, focused bool, width int) string {570 return createInlineTextField(label, value, focused, false, width)571}572573func createInlineTextField(label, value string, focused, dimmed bool, width int) string {574 border := theme.Current().Border575 if focused {576 border = theme.Current().Accent577 }578579 innerWidth := max(width-4, 1)580 labelWidth := lipgloss.Width(label) + 1581 valueStyle := lipgloss.NewStyle().Foreground(theme.Current().FG)582 if dimmed {583 valueStyle = ui.DimItemStyle()584 }585 if focused {586 valueStyle = valueStyle.Foreground(theme.Current().Accent).Bold(true)587 }588 body := lipgloss.JoinHorizontal(589 lipgloss.Top,590 ui.DimItemStyle().Render(label),591 " ",592 renderSingleLine(valueStyle.Render(value), innerWidth-labelWidth),593 )594 return lipgloss.NewStyle().595 Border(lipgloss.RoundedBorder()).596 BorderForeground(border).597 Padding(0, 1).598 Width(width).599 Height(1).600 Render(body)601}602603func createSection(title, content string, focused bool, width int) string {604 border := theme.Current().Border605 titleStyle := ui.PanelTitle()606 if focused {607 border = theme.Current().Accent608 titleStyle = ui.SelectedItemStyle()609 }610 body := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(title), content)611 return lipgloss.NewStyle().612 Border(lipgloss.RoundedBorder()).613 BorderForeground(border).614 Padding(0, 1).615 Width(width).616 Render(body)617}618619func truncatePlainCreateLine(s string, width int) string {620 if width <= 1 || len(s) <= width {621 return s622 }623 return s[:width-1] + "…"624}625626type createGenerateAction struct {627 create createState628}629630type createBackAction struct {631 create createState632}633634func (a createBackAction) applyToModel(m model) (model, tea.Cmd) {635 m.create = a.create636 m = m.persistCreatePresetIfValid()637 m.state = playMenuView638 return m.resizeActiveScreen(), nil639}640641func (a createGenerateAction) applyToModel(m model) (model, tea.Cmd) {642 m.create = a.create643 return m.handleCreateGenerate()644}645646func normalizedCreateSeed(value string) string {647 return sessionflow.NormalizeSeed(value)648}