1// Package nonogram implements the grid-based picture logic puzzle.2package nonogram34import (5 "errors"6 "fmt"7 "strconv"8 "strings"910 "charm.land/bubbles/v2/key"11 tea "charm.land/bubbletea/v2"12 "github.com/FelineStateMachine/puzzletea/game"13)1415const (16 filledTile = '.'17 markedTile = '-'18 emptyTile = ' '19)2021type dragAxis uint82223const (24 dragAxisNone dragAxis = iota25 dragAxisHorizontal26 dragAxisVertical27)2829var _ game.Gamer = Model{}3031type Model struct {32 width, height int33 rowHints TomographyDefinition34 colHints TomographyDefinition35 cursor game.Cursor36 grid grid37 keys KeyMap38 modeTitle string39 showFullHelp bool40 currentHints Hints // cached tomography of the current grid41 solved bool // cached solved state4243 // Hold-to-paint support (requires keyboard enhancements).44 paintBrush rune // 0 = inactive, filledTile or markedTile when painting45 supportsKeyRelease bool // set from tea.KeyboardEnhancementsMsg4647 // Mouse drag support.48 dragging rune // 0 = not dragging, filledTile or markedTile while mouse held49 dragStartCol int50 dragStartRow int51 dragAxis dragAxis5253 // Screen geometry for mouse hit-testing.54 termWidth, termHeight int5556 // Cached grid origin for mouse hit-testing (recomputed on resize/solve).57 originX, originY int58 originValid bool5960 // Debug: last mouse event info.61 lastMouseX, lastMouseY int62 lastMouseBtn string63 lastMouseGridCol int64 lastMouseGridRow int65 lastMouseHit bool66}6768func New(mode NonogramMode, hints Hints) (game.Gamer, error) {69 h, w := mode.Height, mode.Width70 r, c := hints.rows, hints.cols71 if w < r.RequiredLen() {72 return Model{}, errors.New("puzzle width does not support row tomography definition")73 }74 if h < c.RequiredLen() {75 return Model{}, errors.New("puzzle height does not support column tomography definition")76 }77 g := newGrid(createEmptyState(h, w))78 curr := generateTomography(g)79 m := Model{80 width: w,81 height: h,82 rowHints: r,83 colHints: c,84 grid: g,85 keys: DefaultKeyMap,86 modeTitle: mode.Title(),87 currentHints: curr,88 solved: curr.rows.equal(r) && curr.cols.equal(c),89 }9091 return m, nil92}9394func (m Model) Init() tea.Cmd {95 return nil96}9798func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {99 switch msg := msg.(type) {100 case game.HelpToggleMsg:101 m.showFullHelp = msg.Show102 m.originValid = false103104 case tea.KeyboardEnhancementsMsg:105 m.supportsKeyRelease = msg.SupportsEventTypes()106107 case tea.WindowSizeMsg:108 m.termWidth = msg.Width109 m.termHeight = msg.Height110 m.originValid = false111112 case tea.KeyPressMsg:113 switch {114 case key.Matches(msg, m.keys.FillTile):115 if !m.solved {116 m.updateTile(filledTile)117 if m.supportsKeyRelease {118 m.paintBrush = filledTile119 }120 }121 case key.Matches(msg, m.keys.MarkTile):122 if !m.solved {123 m.updateTile(markedTile)124 if m.supportsKeyRelease {125 m.paintBrush = markedTile126 }127 }128 case key.Matches(msg, m.keys.ClearTile):129 if !m.solved {130 m.updateTile(emptyTile)131 m.paintBrush = 0132 }133 default:134 moved := m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)135 if moved && m.paintBrush != 0 && !m.solved {136 m.updateTile(m.paintBrush)137 }138 }139140 case tea.KeyReleaseMsg:141 // Clear paint brush on key release. Cannot use key.Matches here142 // because updateKeyBindings may have disabled the binding after the143 // key was pressed (e.g. filling a cell disables FillTile).144 switch msg.String() {145 case "z", "x":146 m.paintBrush = 0147 }148149 case tea.MouseClickMsg:150 m.lastMouseX, m.lastMouseY = msg.X, msg.Y151 m.lastMouseBtn = msg.String()152 col, row, ok := m.screenToGrid(msg.X, msg.Y, false)153 m.lastMouseGridCol, m.lastMouseGridRow = col, row154 m.lastMouseHit = ok155 if m.solved || !ok {156 break157 }158 m.cursor.X, m.cursor.Y = col, row159 switch msg.Button {160 case tea.MouseLeft:161 m.toggleTile(filledTile)162 m.startDrag(filledTile, col, row)163 case tea.MouseRight:164 m.toggleTile(markedTile)165 m.startDrag(markedTile, col, row)166 }167168 case tea.MouseMotionMsg:169 if m.solved || m.dragging == 0 {170 break171 }172 col, row, ok := m.screenToGrid(msg.X, msg.Y, true)173 if !ok {174 break175 }176 col, row, ok = m.lockedDragTarget(col, row)177 if !ok {178 break179 }180 if col == m.cursor.X && row == m.cursor.Y {181 break182 }183 m.cursor.X, m.cursor.Y = col, row184 // While dragging, always set (don't toggle) to maintain consistency.185 m.updateTile(m.dragging)186187 case tea.MouseReleaseMsg:188 m.clearDrag()189 }190191 m.updateKeyBindings()192 return m, nil193}194195func (m Model) View() string {196 title := game.TitleBarView("Nonogram", m.modeTitle, m.solved)197 board := buildBoardBlock(m)198 status := statusBarView(m.showFullHelp)199 if m.solved {200 return game.ComposeGameView(title, board.Block)201 }202203 return game.ComposeGameViewRows(204 title,205 board.Block,206 game.StableRow(status, statusBarView(false), statusBarView(true)),207 )208}209210func (m Model) GetDebugInfo() string {211 status := "In Progress"212 if m.solved {213 status = "Solved"214 }215216 ox, oy := m.gridOrigin()217 hitStr := "miss"218 if m.lastMouseHit {219 hitStr = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)220 }221222 s := game.DebugHeader("Nonogram", [][2]string{223 {"Status", status},224 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},225 {"Grid Size", fmt.Sprintf("%d x %d", m.width, m.height)},226 {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},227 {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},228 {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hitStr)},229 {"Paint/Drag", fmt.Sprintf("brush=%d drag=%d axis=%d start=(%d, %d) keyrel=%v", m.paintBrush, m.dragging, m.dragAxis, m.dragStartCol, m.dragStartRow, m.supportsKeyRelease)},230 })231232 s += tomoDebugTable("Row Tomography", "Row", m.rowHints, m.currentHints.rows)233 s += tomoDebugTable("Column Tomography", "Col", m.colHints, m.currentHints.cols)234235 return s236}237238func tomoDebugTable(heading, label string, hints, current TomographyDefinition) string {239 var rows [][]string240 for i, hint := range hints {241 var currStr string242 match := false243 if i < len(current) {244 currStr = intSliceStr(current[i])245 match = intSliceEqual(hint, current[i])246 }247 matchStr := "No"248 if match {249 matchStr = "Yes"250 }251 rows = append(rows, []string{fmt.Sprintf("%d", i), intSliceStr(hint), currStr, matchStr})252 }253 return game.DebugTable(heading, []string{label, "Hint", "Current", "Match"}, rows)254}255256func intSliceStr(s []int) string {257 if len(s) == 0 {258 return "[]"259 }260 var b strings.Builder261 for i, v := range s {262 if i > 0 {263 b.WriteString(", ")264 }265 b.WriteString(strconv.Itoa(v))266 }267 return b.String()268}269270func (m Model) SetTitle(t string) game.Gamer {271 m.modeTitle = t272 return m273}274275func (m Model) IsSolved() bool {276 return m.solved277}278279func (m Model) Reset() game.Gamer {280 g := newGrid(createEmptyState(m.height, m.width))281 curr := generateTomography(g)282 m.grid = g283 m.currentHints = curr284 m.solved = false285 m.cursor = game.Cursor{}286 m.paintBrush = 0287 m.clearDrag()288 m.originValid = false289 return m290}291292func (m *Model) updateTile(r rune) {293 m.grid[m.cursor.Y][m.cursor.X] = r294 m.currentHints = generateTomography(m.grid)295 wasSolved := m.solved296 m.solved = m.currentHints.rows.equal(m.rowHints) && m.currentHints.cols.equal(m.colHints)297 if m.solved != wasSolved {298 m.originValid = false299 }300}301302// toggleTile toggles a cell between the given target state and empty.303// If the cell already has the target value, it becomes empty; otherwise304// it becomes the target.305func (m *Model) toggleTile(target rune) {306 if m.grid[m.cursor.Y][m.cursor.X] == target {307 m.updateTile(emptyTile)308 } else {309 m.updateTile(target)310 }311}312313func (m *Model) startDrag(brush rune, col, row int) {314 m.dragging = brush315 m.dragStartCol = col316 m.dragStartRow = row317 m.dragAxis = dragAxisNone318}319320func (m *Model) clearDrag() {321 m.dragging = 0322 m.dragStartCol = 0323 m.dragStartRow = 0324 m.dragAxis = dragAxisNone325}