- commit
- 8d753d50552902c40d3cfb803f82459a4a3068c3
- parent
- 553f7e3eed102e41590caaa4f4123f727d0e1bf9
- author
- Dami <dami.etoile@gmail.com>
| M | wordsearch/ | 110 | +++++++++++++++++++++++++++++ |
| M | wordsearch/ | 14 | ++++ |
| A | wordsearch/ | 112 | ++++++++++++++++++++++++++++++ |
| M | wordsearch/ | 4 | +- |
| M | wordsearch/ | 51 | ++++++++++++++ |
@@ -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 @@ -15,6 +15,8 @@ Press `Escape` to cancel a selection.
1515 1616 ## Controls1717 18+### Keyboard19+1820 | Key | Action |1921 |-----|--------|2022 | `Arrows` / `wasd` / `hjkl` | Move cursor |@@ -24,6 +26,18 @@ Press `Escape` to cancel a selection.
2426 | `Ctrl+R` | Reset puzzle |2527 | `Ctrl+N` | Return to main menu |2628 29+### Mouse30+31+| Action | Effect |32+|--------|--------|33+| Left click + drag | Select a word by dragging from first to last letter |34+| Left click | Set the start of a selection (release and click end to confirm) |35+| Right click | Cancel current selection |36+37+Drag along a horizontal, vertical, or diagonal line to select a word.38+If the selection matches a hidden word (in either direction), it is39+automatically found on release.40+2741 ## Tips2842 2943 - **Start with uncommon letters.** Letters like Q, Z, X, and J appear far@@ -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+}@@ -89,9 +89,9 @@ func renderView(m Model) string {
8989 9090 func statusBarView(showFullHelp bool) string {9191 if showFullHelp {92- return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select esc: cancel ctrl+n: menu ctrl+r: reset ctrl+h: help")92+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select esc: cancel mouse: click & drag ctrl+n: menu ctrl+r: reset ctrl+h: help")9393 }94- return game.StatusBarStyle.Render("enter/space: select esc: cancel")94+ return game.StatusBarStyle.Render("enter/space: select esc: cancel mouse: click & drag")9595 }9696 9797 func renderGrid(m Model) string {@@ -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) {