clone

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

add mouse to word search

Dami committed 8d753d5
commit
8d753d50552902c40d3cfb803f82459a4a3068c3
parent
553f7e3eed102e41590caaa4f4123f727d0e1bf9
author
Dami <dami.etoile@gmail.com>
5 files changed, +289 -2
Mwordsearch/Model.go110+++++++++++++++++++++++++++++
Mwordsearch/help.md14++++
Awordsearch/mouse.go112++++++++++++++++++++++++++++++
Mwordsearch/style.go4+-
Mwordsearch/wordsearch_test.go51++++++++++++++
M wordsearch/Model.go +110 -0
@@ -31,6 +31,23 @@ type Model struct {
3131 solved bool3232 showFullHelp bool3333 foundCells [][]bool34+35+ // Mouse drag support: true while left-button is held after clicking a cell.36+ mouseDragging bool37+38+ // Screen geometry for mouse hit-testing.39+ termWidth, termHeight int40+41+ // Cached grid origin for mouse hit-testing (recomputed on resize/solve).42+ originX, originY int43+ originValid bool44+45+ // Debug: last mouse event info.46+ lastMouseX, lastMouseY int47+ lastMouseBtn string48+ lastMouseGridCol int49+ lastMouseGridRow int50+ lastMouseHit bool3451 }3552 3653 func buildFoundCells(width, height int, words []Word) [][]bool {
@@ -71,12 +88,90 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
7188 switch msg := msg.(type) {7289 case game.HelpToggleMsg:7390 m.showFullHelp = msg.Show91+92+ case tea.WindowSizeMsg:93+ m.termWidth = msg.Width94+ m.termHeight = msg.Height95+ m.originValid = false96+7497 case tea.KeyPressMsg:7598 return m.handleKeyPress(msg)99+100+ case tea.MouseClickMsg:101+ m.handleMouseClick(msg)102+103+ case tea.MouseMotionMsg:104+ m.handleMouseMotion(msg)105+106+ case tea.MouseReleaseMsg:107+ m.handleMouseRelease()76108 }77109 return m, nil78110 }79111 112+func (m *Model) handleMouseClick(msg tea.MouseClickMsg) {113+ m.lastMouseX, m.lastMouseY = msg.X, msg.Y114+ m.lastMouseBtn = msg.String()115+116+ col, row, ok := m.screenToGrid(msg.X, msg.Y)117+ m.lastMouseGridCol, m.lastMouseGridRow = col, row118+ m.lastMouseHit = ok119+120+ if m.solved || !ok {121+ return122+ }123+124+ switch msg.Button {125+ case tea.MouseLeft:126+ // Click sets the selection start and begins a drag.127+ m.cursor.X, m.cursor.Y = col, row128+ m.selectionStart = m.cursor129+ m.selection = startSelected130+ m.mouseDragging = true131+132+ case tea.MouseRight:133+ // Right-click cancels the current selection.134+ m.selection = noSelection135+ m.mouseDragging = false136+ }137+}138+139+func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) {140+ if m.solved || !m.mouseDragging || m.selection != startSelected {141+ return142+ }143+144+ col, row, ok := m.screenToGrid(msg.X, msg.Y)145+ if !ok {146+ return147+ }148+149+ // Move cursor to track the drag endpoint; the existing selection150+ // rendering uses selectionStart → cursor.151+ m.cursor.X, m.cursor.Y = col, row152+}153+154+func (m *Model) handleMouseRelease() {155+ if !m.mouseDragging {156+ return157+ }158+ m.mouseDragging = false159+160+ if m.selection != startSelected {161+ return162+ }163+164+ // Same cell as start: keep selection active (start is set, waiting165+ // for a second click/drag to define the end — mirrors keyboard flow).166+ if m.cursor.X == m.selectionStart.X && m.cursor.Y == m.selectionStart.Y {167+ return168+ }169+170+ // Try to validate and submit the selection.171+ m.validateSelection()172+ m.selection = noSelection173+}174+80175 func (m Model) handleKeyPress(msg tea.KeyPressMsg) (game.Gamer, tea.Cmd) {81176 switch {82177 case key.Matches(msg, m.keys.Select):
@@ -137,6 +232,9 @@ func (m *Model) checkWin() {
137232 break138233 }139234 }235+ if allFound != m.solved {236+ m.originValid = false237+ }140238 m.solved = allFound141239 }142240
@@ -145,6 +243,12 @@ func (m Model) View() string {
145243 }146244 147245 func (m Model) GetDebugInfo() string {246+ ox, oy := m.gridOrigin()247+ hitStr := "miss"248+ if m.lastMouseHit {249+ hitStr = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)250+ }251+148252 rows := [][2]string{149253 {"Grid Size", fmt.Sprintf("%dx%d", m.width, m.height)},150254 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},
@@ -156,6 +260,10 @@ func (m Model) GetDebugInfo() string {
156260 rows = append(rows,157261 [2]string{"Words Found", fmt.Sprintf("%d/%d", m.countFoundWords(), len(m.words))},158262 [2]string{"Won", fmt.Sprintf("%v", m.solved)},263+ [2]string{"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},264+ [2]string{"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},265+ [2]string{"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hitStr)},266+ [2]string{"Mouse Drag", fmt.Sprintf("%v", m.mouseDragging)},159267 )160268 161269 s := game.DebugHeader("Word Search", rows)
@@ -204,6 +312,8 @@ func (m Model) Reset() game.Gamer {
204312 m.selection = noSelection205313 m.selectionStart = game.Cursor{}206314 m.cursor = game.Cursor{}315+ m.mouseDragging = false316+ m.originValid = false207317 return m208318 }209319
M wordsearch/help.md +14 -0
A wordsearch/mouse.go +112 -0
@@ -0,0 +1,112 @@
1+package wordsearch2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+const cellWidth = 311+12+// screenToGrid converts terminal screen coordinates to grid cell coordinates.13+// Returns (col, row, ok) where ok is false if the click landed outside the grid.14+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {15+ ox, oy := m.cachedGridOrigin()16+ lx := screenX - ox17+ ly := screenY - oy18+ if lx < 0 || ly < 0 {19+ return 0, 0, false20+ }21+22+ // No separators: simple division by cell dimensions.23+ col = lx / cellWidth24+ row = ly // cellHeight is 125+26+ if col < 0 || col >= m.width || row < 0 || row >= m.height {27+ return 0, 0, false28+ }29+30+ return col, row, true31+}32+33+// cachedGridOrigin returns the screen position of the top-left corner of34+// cell (0,0), using a cached value when available. The cache is invalidated35+// on terminal resize and solve-state changes.36+func (m *Model) cachedGridOrigin() (x, y int) {37+ if m.originValid {38+ return m.originX, m.originY39+ }40+ x, y = m.gridOrigin()41+ m.originX, m.originY = x, y42+ m.originValid = true43+ return x, y44+}45+46+// gridOrigin computes the screen position of the top-left corner of the47+// first grid cell.48+//49+// The root view does:50+//51+// lipgloss.Place(termWidth, termHeight, Center, Center, gameView)52+//53+// Within the game view the layout is:54+//55+// title56+// ┌─────────────┐ spacer ┌──────────────┐57+// │ letter grid │ │ word list │58+// └─────────────┘ └──────────────┘59+// status60+//61+// The grid + spacer + word list form `mainView`, which is centered as a62+// block within the overall view.63+func (m *Model) gridOrigin() (x, y int) {64+ gameView := m.View()65+ viewWidth := lipgloss.Width(gameView)66+ viewHeight := lipgloss.Height(gameView)67+68+ if viewWidth > m.termWidth {69+ viewWidth = m.termWidth70+ }71+72+ // Centering offset applied by the root's lipgloss.Place().73+ centerX := max((m.termWidth-viewWidth)/2, 0)74+ centerY := max((m.termHeight-viewHeight)/2, 0)75+76+ // Measure the title height.77+ title := game.TitleBarView("Word Search", m.modeTitle, m.solved)78+ titleHeight := strings.Count(title, "\n") + 179+80+ // The mainView (grid + spacer + word list) is centered within the81+ // overall view. Measure its width to compute left padding.82+ gridContent := renderGrid(*m)83+ gBorder := gridBorderStyle84+ if m.solved {85+ gBorder = gridBorderSolvedStyle86+ }87+ gridRendered := gBorder.Render(gridContent)88+89+ wordListContent := renderWordList(*m)90+ wBorder := wordListBorderStyle91+ if m.solved {92+ wBorder = wordListBorderSolvedStyle93+ }94+ gridHeight := lipgloss.Height(gridRendered)95+ wBorder = wBorder.Height(gridHeight - 2)96+ wordListRendered := wBorder.Render(wordListContent)97+98+ spacer := strings.Repeat(" ", 2)99+ mainView := lipgloss.JoinHorizontal(lipgloss.Top, gridRendered, spacer, wordListRendered)100+ mainViewWidth := lipgloss.Width(mainView)101+102+ // The mainView block is centered within the full view width.103+ mainViewPadLeft := max((viewWidth-mainViewWidth)/2, 0)104+105+ // Border adds 1 character on the left and 1 line on top.106+ const borderLeft = 1107+ const borderTop = 1108+109+ x = centerX + mainViewPadLeft + borderLeft110+ y = centerY + titleHeight + borderTop111+ return x, y112+}
M wordsearch/style.go +2 -2
M wordsearch/wordsearch_test.go +51 -0
@@ -566,6 +566,57 @@ func TestGenerateWordSearch(t *testing.T) {
566566 })567567 }568568 569+// --- screenToGrid (P1) ---570+571+func TestScreenToGrid(t *testing.T) {572+ m := &Model{573+ width: 10,574+ height: 10,575+ grid: createEmptyGrid(10, 10),576+ words: nil,577+ keys: DefaultKeyMap,578+ modeTitle: "Test",579+ foundCells: buildFoundCells(10, 10, nil),580+ termWidth: 120,581+ termHeight: 40,582+ }583+584+ ox, oy := m.gridOrigin()585+586+ tests := []struct {587+ name string588+ screenX int589+ screenY int590+ wantCol int591+ wantRow int592+ wantOk bool593+ }{594+ {"origin cell", ox, oy, 0, 0, true},595+ {"cell (1,0)", ox + cellWidth, oy, 1, 0, true},596+ {"cell (0,1)", ox, oy + 1, 0, 1, true},597+ {"cell (2,3)", ox + 2*cellWidth, oy + 3, 2, 3, true},598+ {"cell (9,9) last", ox + 9*cellWidth, oy + 9, 9, 9, true},599+ {"outside left", ox - 1, oy, 0, 0, false},600+ {"outside top", ox, oy - 1, 0, 0, false},601+ {"outside right", ox + 10*cellWidth, oy, 0, 0, false},602+ {"outside bottom", ox, oy + 10, 0, 0, false},603+ {"far outside", 0, 0, 0, 0, false},604+ }605+606+ for _, tt := range tests {607+ t.Run(tt.name, func(t *testing.T) {608+ col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)609+ if ok != tt.wantOk {610+ t.Errorf("ok = %v, want %v", ok, tt.wantOk)611+ }612+ if ok && (col != tt.wantCol || row != tt.wantRow) {613+ t.Errorf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)",614+ tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)615+ }616+ })617+ }618+}619+569620 // --- Save/Load round-trip (P1) ---570621 571622 func TestSaveLoadRoundTrip(t *testing.T) {