- commit
- 553f7e3eed102e41590caaa4f4123f727d0e1bf9
- parent
- 93913b96d55554cb57417365554eb5cea9c3792c
- author
- Dami <dami.etoile@gmail.com>
| M | lightsout/ | 13 | ++ |
| M | lightsout/ | 8 | + |
| M | lightsout/ | 47 | +++++++- |
| M | nonogram/ | 15 | ++- |
| M | shikaku/ | 198 | ++++++++++++++++++++++++++++++ |
| M | shikaku/ | 18 | ++- |
| A | shikaku/ | 94 | ++++++++++++++ |
| M | shikaku/ | 11 | ++ |
| M | shikaku/ | 115 | +++++++++++++++++ |
| M | shikaku/ | 28 | +++- |
@@ -20,6 +20,9 @@ type Model struct {
2020 keys KeyMap2121 modeTitle string2222 showFullHelp bool23+24+ termWidth int25+ termHeight int2326 }2427 2528 var _ game.Gamer = Model{}@@ -58,6 +61,16 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5861 switch msg := msg.(type) {5962 case game.HelpToggleMsg:6063 m.showFullHelp = msg.Show64+ case tea.WindowSizeMsg:65+ m.termWidth = msg.Width66+ m.termHeight = msg.Height67+ case tea.MouseClickMsg:68+ if msg.Button == tea.MouseLeft && !m.IsSolved() {69+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {70+ m.cursor.X, m.cursor.Y = col, row71+ Toggle(m.grid, col, row)72+ }73+ }6174 case tea.KeyPressMsg:6275 switch {6376 case key.Matches(msg, m.keys.Toggle):@@ -12,6 +12,8 @@ Toggle lights in a cross pattern to turn them all off.
1212 1313 ## Controls1414 15+### Keyboard16+1517 | Key | Action |1618 |-----|--------|1719 | `Arrows` / `wasd` / `hjkl` | Move cursor |@@ -20,6 +22,12 @@ Toggle lights in a cross pattern to turn them all off.
2022 | `Ctrl+R` | Reset puzzle |2123 | `Ctrl+N` | Return to main menu |2224 25+### Mouse26+27+| Action | Effect |28+|--------|--------|29+| Left click | Toggle light (and its neighbors) |30+2331 ## Tips2432 2533 - **Toggling the same cell twice cancels out.** Every move is its own@@ -8,6 +8,49 @@ import (
88 "github.com/FelineStateMachine/puzzletea/game"99 )1010 11+// screenToGrid converts terminal screen coordinates to grid cell coordinates.12+// Returns (col, row, ok) where ok is false if the click landed outside the grid.13+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {14+ gameView := m.View()15+ viewWidth := lipgloss.Width(gameView)16+ viewHeight := lipgloss.Height(gameView)17+18+ if viewWidth > m.termWidth {19+ viewWidth = m.termWidth20+ }21+22+ centerX := max((m.termWidth-viewWidth)/2, 0)23+ centerY := max((m.termHeight-viewHeight)/2, 0)24+25+ // Title bar is always 2 lines (title + blank/subtitle line).26+ titleHeight := strings.Count(game.TitleBarView("Lights Out", m.modeTitle, m.IsSolved()), "\n") + 127+28+ // The grid border adds 1 character on each side.29+ const borderSize = 130+31+ // Grid origin within the centered view.32+ gridInnerWidth := m.width * cellWidth33+ gridOuterWidth := gridInnerWidth + 2*borderSize34+ gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)35+36+ gridX := centerX + gridPadLeft + borderSize37+ gridY := centerY + titleHeight + borderSize38+39+ lx := screenX - gridX40+ ly := screenY - gridY41+ if lx < 0 || ly < 0 {42+ return 0, 0, false43+ }44+45+ col = lx / cellWidth46+ row = ly / cellHeight47+ if col >= m.width || row >= m.height {48+ return 0, 0, false49+ }50+51+ return col, row, true52+}53+1154 var (1255 baseStyle = lipgloss.NewStyle()1356 @@ -80,7 +123,7 @@ func gridView(g [][]bool, c game.Cursor, solved bool) string {
80123 81124 func statusBarView(showFullHelp bool) string {82125 if showFullHelp {83- return game.StatusBarStyle.Render("arrows/wasd: move enter/space: toggle ctrl+n: menu ctrl+r: reset ctrl+h: help")126+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space/click: toggle ctrl+n: menu ctrl+r: reset ctrl+h: help")84127 }85- return game.StatusBarStyle.Render("enter/space: toggle")128+ return game.StatusBarStyle.Render("enter/space/click: toggle")86129 }@@ -13,16 +13,27 @@ Fill cells to match row and column hints, revealing a hidden pattern.
1313 1414 ## Controls1515 16+### Keyboard17+1618 | Key | Action |1719 |-----|--------|1820 | `Arrows` / `wasd` / `hjkl` | Move cursor |19-| `z` | Fill cell |20-| `x` | Mark cell |21+| `z` | Fill cell (hold to paint) |22+| `x` | Mark cell (hold to paint) |2123 | `Backspace` | Clear cell |2224 | `Ctrl+H` | Toggle help bar |2325 | `Ctrl+R` | Reset puzzle |2426 | `Ctrl+N` | Return to main menu |2527 28+### Mouse29+30+| Action | Effect |31+|--------|--------|32+| Left click | Toggle fill on a cell |33+| Right click | Toggle mark on a cell |34+| Left drag | Paint filled cells |35+| Right drag | Paint marked cells |36+2637 ## Tips2738 2839 - **Start with the largest hints.** If a hint is close to the row or@@ -38,6 +38,24 @@ type Model struct {
3838 keys KeyMap3939 modeTitle string4040 showFullHelp bool41+42+ // Mouse drag support: anchor is the cell where the drag started.43+ mouseDragAnchor *[2]int // nil = not dragging, [col, row] of click origin44+ mousePreview *Rectangle // preview rect computed during drag (for rendering)45+46+ // Screen geometry for mouse hit-testing.47+ termWidth, termHeight int48+49+ // Cached grid origin for mouse hit-testing (recomputed on resize/solve).50+ originX, originY int51+ originValid bool52+53+ // Debug: last mouse event info.54+ lastMouseX, lastMouseY int55+ lastMouseBtn string56+ lastMouseGridCol int57+ lastMouseGridRow int58+ lastMouseHit bool4159 }4260 4361 // New creates a new Shikaku game model.@@ -56,6 +74,12 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5674 switch msg := msg.(type) {5775 case game.HelpToggleMsg:5876 m.showFullHelp = msg.Show77+78+ case tea.WindowSizeMsg:79+ m.termWidth = msg.Width80+ m.termHeight = msg.Height81+ m.originValid = false82+5983 case tea.KeyPressMsg:6084 if m.puzzle.IsSolved() {6185 m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)@@ -64,10 +88,166 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6488 } else {6589 m = m.handleNavMode(msg)6690 }91+92+ case tea.MouseClickMsg:93+ m = m.handleMouseClick(msg)94+95+ case tea.MouseMotionMsg:96+ m = m.handleMouseMotion(msg)97+98+ case tea.MouseReleaseMsg:99+ m = m.handleMouseRelease(msg)67100 }68101 return m, nil69102 }70103 104+func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model {105+ m.lastMouseX, m.lastMouseY = msg.X, msg.Y106+ m.lastMouseBtn = msg.String()107+108+ col, row, ok := m.screenToGrid(msg.X, msg.Y)109+ m.lastMouseGridCol, m.lastMouseGridRow = col, row110+ m.lastMouseHit = ok111+112+ if m.puzzle.IsSolved() || !ok {113+ return m114+ }115+116+ m.cursor.X, m.cursor.Y = col, row117+118+ switch msg.Button {119+ case tea.MouseLeft:120+ // Start a drag from any cell.121+ m.mouseDragAnchor = &[2]int{col, row}122+ r := rectFromCorners(col, row, col, row)123+ m.mousePreview = &r124+ m.selectedClue = nil125+126+ // Sync expansion if the anchor is on a clue (for seamless127+ // keyboard takeover if the user releases without moving).128+ m.syncExpansionFromPreview()129+130+ case tea.MouseRight:131+ // Right-click deletes rectangle at cursor.132+ owner := m.puzzle.CellOwner(col, row)133+ if owner >= 0 {134+ m.puzzle.RemoveRectangle(owner)135+ m.originValid = false136+ }137+ }138+ return m139+}140+141+func (m Model) handleMouseMotion(msg tea.MouseMotionMsg) Model {142+ if m.puzzle.IsSolved() || m.mouseDragAnchor == nil {143+ return m144+ }145+146+ col, row, ok := m.screenToGrid(msg.X, msg.Y)147+ if !ok {148+ return m149+ }150+151+ // Update the preview rectangle spanning anchor to current cell.152+ r := rectFromCorners(m.mouseDragAnchor[0], m.mouseDragAnchor[1], col, row)153+ m.mousePreview = &r154+ m.syncExpansionFromPreview()155+ return m156+}157+158+func (m Model) handleMouseRelease(msg tea.MouseReleaseMsg) Model {159+ if m.mouseDragAnchor == nil {160+ return m161+ }162+163+ preview := m.mousePreview164+ m.mouseDragAnchor = nil165+ m.mousePreview = nil166+167+ if preview == nil {168+ m.selectedClue = nil169+ return m170+ }171+172+ // Find clues inside the drawn rectangle.173+ clues := m.puzzle.CluesInRect(*preview)174+ if len(clues) != 1 {175+ // Not exactly one clue: cancel the preview.176+ m.selectedClue = nil177+ return m178+ }179+180+ clue := clues[0]181+ rect := Rectangle{182+ ClueID: clue.ID,183+ X: preview.X,184+ Y: preview.Y,185+ W: preview.W,186+ H: preview.H,187+ }188+189+ // Place if valid: correct area and no overlap.190+ if rect.Area() == clue.Value && !m.puzzle.Overlaps(rect, clue.ID) {191+ m.puzzle.SetRectangle(rect)192+ m.selectedClue = nil193+ m.originValid = false194+ } else {195+ // Invalid: enter keyboard expansion mode on this clue so the196+ // player can fine-tune.197+ m.selectedClue = &clue.ID198+ m.expansion = expansion{clueID: clue.ID}199+ m.expansion.left = clue.X - preview.X200+ m.expansion.right = preview.X + preview.W - 1 - clue.X201+ m.expansion.up = clue.Y - preview.Y202+ m.expansion.down = preview.Y + preview.H - 1 - clue.Y203+ m.cursor.X, m.cursor.Y = clue.X, clue.Y204+ }205+ return m206+}207+208+// syncExpansionFromPreview updates selectedClue and expansion to match209+// the current mousePreview so that the existing preview rendering works.210+// If the preview contains exactly one clue, that clue is selected;211+// otherwise selectedClue is cleared (the preview is still rendered via212+// mousePreview in gridView).213+func (m *Model) syncExpansionFromPreview() {214+ if m.mousePreview == nil {215+ m.selectedClue = nil216+ return217+ }218+219+ clues := m.puzzle.CluesInRect(*m.mousePreview)220+ if len(clues) == 1 {221+ clue := clues[0]222+ m.selectedClue = &clue.ID223+ m.expansion = expansion{clueID: clue.ID}224+ m.expansion.left = clue.X - m.mousePreview.X225+ m.expansion.right = m.mousePreview.X + m.mousePreview.W - 1 - clue.X226+ m.expansion.up = clue.Y - m.mousePreview.Y227+ m.expansion.down = m.mousePreview.Y + m.mousePreview.H - 1 - clue.Y228+ } else {229+ m.selectedClue = nil230+ }231+}232+233+// rectFromCorners builds a Rectangle spanning two arbitrary grid cells.234+func rectFromCorners(x1, y1, x2, y2 int) Rectangle {235+ minX, maxX := x1, x2236+ if x2 < x1 {237+ minX, maxX = x2, x1238+ }239+ minY, maxY := y1, y2240+ if y2 < y1 {241+ minY, maxY = y2, y1242+ }243+ return Rectangle{244+ X: minX,245+ Y: minY,246+ W: maxX - minX + 1,247+ H: maxY - minY + 1,248+ }249+}250+71251 func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {72252 switch {73253 case key.Matches(msg, m.keys.Select):@@ -174,6 +354,9 @@ func (m Model) Reset() game.Gamer {
174354 m.puzzle.Rectangles = nil175355 m.cursor = game.Cursor{}176356 m.selectedClue = nil357+ m.mouseDragAnchor = nil358+ m.mousePreview = nil359+ m.originValid = false177360 return m178361 }179362 @@ -188,6 +371,17 @@ func (m Model) GetDebugInfo() string {
188371 selectedStr = fmt.Sprintf("%d", *m.selectedClue)189372 }190373 374+ ox, oy := m.gridOrigin()375+ hitStr := "miss"376+ if m.lastMouseHit {377+ hitStr = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)378+ }379+380+ dragStr := "None"381+ if m.mouseDragAnchor != nil {382+ dragStr = fmt.Sprintf("anchor=(%d, %d)", m.mouseDragAnchor[0], m.mouseDragAnchor[1])383+ }384+191385 s := game.DebugHeader("Shikaku", [][2]string{192386 {"Status", status},193387 {"Grid Size", fmt.Sprintf("%dx%d", m.puzzle.Width, m.puzzle.Height)},@@ -195,6 +389,10 @@ func (m Model) GetDebugInfo() string {
195389 {"Rectangles", fmt.Sprintf("%d", len(m.puzzle.Rectangles))},196390 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},197391 {"Selected Clue", selectedStr},392+ {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},393+ {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},394+ {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hitStr)},395+ {"Mouse Drag", dragStr},198396 })199397 200398 var rows [][]string@@ -12,7 +12,7 @@ The puzzle is solved when all three rules hold simultaneously.
1212 1313 ## Controls1414 15-### Navigation Mode15+### Keyboard — Navigation Mode1616 1717 | Key | Action |1818 |-----|--------|@@ -20,7 +20,7 @@ The puzzle is solved when all three rules hold simultaneously.
2020 | `Enter` / `Space` | Select clue to expand |2121 | `Backspace` | Delete rectangle at cursor |2222 23-### Expansion Mode23+### Keyboard — Expansion Mode2424 2525 After selecting a clue, you enter Expansion Mode. Each side of the2626 rectangle is controlled independently.@@ -33,8 +33,18 @@ rectangle is controlled independently.
3333 | `Escape` | Cancel, discard preview |3434 | `Backspace` | Delete existing rectangle, return to nav |3535 36-The preview turns **green** when the area matches the clue value and there37-are no overlaps, or **red** otherwise. You can only confirm a green preview.36+### Mouse37+38+| Action | Effect |39+|--------|--------|40+| Left click + drag | Draw a rectangle from any cell |41+| Release | Place the rectangle if it contains exactly one clue, the area matches, and there are no overlaps |42+| Right click | Delete rectangle at cursor |43+44+You can start a drag from **any cell**, not just a numbered clue.45+While dragging, the preview turns **green** when valid or **red**46+otherwise. If the rectangle is invalid on release, you stay in47+Expansion Mode so you can fine-tune with the keyboard.3848 3949 ---4050 @@ -0,0 +1,94 @@
1+package shikaku2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// screenToGrid converts terminal screen coordinates to grid cell coordinates.11+// Returns (col, row, ok) where ok is false if the click landed outside the grid.12+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {13+ ox, oy := m.cachedGridOrigin()14+ lx := screenX - ox15+ ly := screenY - oy16+ if lx < 0 || ly < 0 {17+ return 0, 0, false18+ }19+20+ // Shikaku has no separators: simple division by cell dimensions.21+ col = lx / cellWidth22+ row = ly // cellHeight is 123+24+ if col < 0 || col >= m.puzzle.Width || row < 0 || row >= m.puzzle.Height {25+ return 0, 0, false26+ }27+28+ return col, row, true29+}30+31+// cachedGridOrigin returns the screen position of the top-left corner of32+// cell (0,0), using a cached value when available. The cache is invalidated33+// on terminal resize and solve-state changes.34+func (m *Model) cachedGridOrigin() (x, y int) {35+ if m.originValid {36+ return m.originX, m.originY37+ }38+ x, y = m.gridOrigin()39+ m.originX, m.originY = x, y40+ m.originValid = true41+ return x, y42+}43+44+// gridOrigin computes the screen position of the top-left corner of the45+// first grid cell. Mouse coordinates are terminal-absolute, so we need to46+// account for how the root model centers the game's View() output.47+//48+// The root view does:49+//50+// lipgloss.Place(termWidth, termHeight, Center, Center, gameView)51+//52+// So the centering offset is (termWidth - viewWidth) / 2 for X and53+// (termHeight - viewHeight) / 2 for Y.54+//55+// Within the game view the layout is:56+//57+// title58+// ┌─────────────┐ <- border (1 line top)59+// │ cell grid │ <- border adds 1 char left60+// └─────────────┘61+// info62+// status63+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+ solved := m.puzzle.IsSolved()78+ title := game.TitleBarView("Shikaku", m.modeTitle, solved)79+ titleHeight := strings.Count(title, "\n") + 180+81+ // The grid is rendered inside a border. Measure its outer width to82+ // compute horizontal padding from JoinVertical(Center, ...).83+ grid := gridView(*m, solved)84+ gridOuterWidth := lipgloss.Width(grid)85+ gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)86+87+ // Border adds 1 character on the left and 1 line on top.88+ const borderLeft = 189+ const borderTop = 190+91+ x = centerX + gridPadLeft + borderLeft92+ y = centerY + titleHeight + borderTop93+ return x, y94+}@@ -120,6 +120,17 @@ func (p *Puzzle) RemoveRectangle(clueID int) {
120120 }121121 }122122 123+// CluesInRect returns all clues contained within the given rectangle bounds.124+func (p *Puzzle) CluesInRect(r Rectangle) []*Clue {125+ var result []*Clue126+ for i := range p.Clues {127+ if r.Contains(p.Clues[i].X, p.Clues[i].Y) {128+ result = append(result, &p.Clues[i])129+ }130+ }131+ return result132+}133+123134 // IsSolved reports whether the puzzle is complete:124135 // every cell is covered exactly once and each rectangle contains125136 // exactly one clue whose value equals the rectangle's area.@@ -496,6 +496,121 @@ func TestGeneratePuzzle(t *testing.T) {
496496 })497497 }498498 499+// --- screenToGrid (P1) ---500+501+func TestScreenToGrid(t *testing.T) {502+ m := &Model{503+ puzzle: Puzzle{504+ Width: 5,505+ Height: 5,506+ Clues: []Clue{507+ {ID: 0, X: 0, Y: 0, Value: 4},508+ },509+ },510+ keys: DefaultKeyMap,511+ modeTitle: "Test",512+ termWidth: 120,513+ termHeight: 40,514+ }515+516+ ox, oy := m.gridOrigin()517+518+ tests := []struct {519+ name string520+ screenX int521+ screenY int522+ wantCol int523+ wantRow int524+ wantOk bool525+ }{526+ {"origin cell", ox, oy, 0, 0, true},527+ {"cell (1,0)", ox + cellWidth, oy, 1, 0, true},528+ {"cell (0,1)", ox, oy + 1, 0, 1, true},529+ {"cell (2,3)", ox + 2*cellWidth, oy + 3, 2, 3, true},530+ {"cell (4,4) last", ox + 4*cellWidth, oy + 4, 4, 4, true},531+ {"outside left", ox - 1, oy, 0, 0, false},532+ {"outside top", ox, oy - 1, 0, 0, false},533+ {"outside right", ox + 5*cellWidth, oy, 0, 0, false},534+ {"outside bottom", ox, oy + 5, 0, 0, false},535+ {"far outside", 0, 0, 0, 0, false},536+ }537+538+ for _, tt := range tests {539+ t.Run(tt.name, func(t *testing.T) {540+ col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)541+ if ok != tt.wantOk {542+ t.Errorf("ok = %v, want %v", ok, tt.wantOk)543+ }544+ if ok && (col != tt.wantCol || row != tt.wantRow) {545+ t.Errorf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)",546+ tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)547+ }548+ })549+ }550+}551+552+// --- rectFromCorners (P1) ---553+554+func TestRectFromCorners(t *testing.T) {555+ tests := []struct {556+ name string557+ x1, y1, x2, y2 int558+ wantX, wantY, wantW, wantH int559+ }{560+ {"same cell", 3, 3, 3, 3, 3, 3, 1, 1},561+ {"drag right", 1, 2, 4, 2, 1, 2, 4, 1},562+ {"drag left", 4, 2, 1, 2, 1, 2, 4, 1},563+ {"drag down", 2, 1, 2, 5, 2, 1, 1, 5},564+ {"drag up", 2, 5, 2, 1, 2, 1, 1, 5},565+ {"drag down-right", 1, 1, 4, 3, 1, 1, 4, 3},566+ {"drag up-left", 4, 3, 1, 1, 1, 1, 4, 3},567+ }568+569+ for _, tt := range tests {570+ t.Run(tt.name, func(t *testing.T) {571+ r := rectFromCorners(tt.x1, tt.y1, tt.x2, tt.y2)572+ if r.X != tt.wantX || r.Y != tt.wantY || r.W != tt.wantW || r.H != tt.wantH {573+ t.Errorf("rectFromCorners(%d,%d, %d,%d) = (%d,%d) %dx%d, want (%d,%d) %dx%d",574+ tt.x1, tt.y1, tt.x2, tt.y2,575+ r.X, r.Y, r.W, r.H,576+ tt.wantX, tt.wantY, tt.wantW, tt.wantH)577+ }578+ })579+ }580+}581+582+// --- CluesInRect (P1) ---583+584+func TestCluesInRect(t *testing.T) {585+ p := simplePuzzle()586+587+ tests := []struct {588+ name string589+ rect Rectangle590+ wantCount int591+ wantIDs []int592+ }{593+ {"single clue", Rectangle{X: 0, Y: 0, W: 1, H: 1}, 1, []int{0}},594+ {"no clues", Rectangle{X: 1, Y: 1, W: 1, H: 1}, 0, nil},595+ {"two clues", Rectangle{X: 0, Y: 0, W: 4, H: 1}, 2, []int{0, 3}},596+ {"all clues", Rectangle{X: 0, Y: 0, W: 4, H: 4}, 4, []int{0, 1, 2, 3}},597+ }598+599+ for _, tt := range tests {600+ t.Run(tt.name, func(t *testing.T) {601+ clues := p.CluesInRect(tt.rect)602+ if len(clues) != tt.wantCount {603+ t.Fatalf("CluesInRect returned %d clues, want %d", len(clues), tt.wantCount)604+ }605+ for i, c := range clues {606+ if c.ID != tt.wantIDs[i] {607+ t.Errorf("clue[%d].ID = %d, want %d", i, c.ID, tt.wantIDs[i])608+ }609+ }610+ })611+ }612+}613+499614 // --- Mode Spawn ---500615 501616 func TestModeSpawn(t *testing.T) {@@ -63,10 +63,19 @@ var (
6363 )6464 6565 func gridView(m Model, solved bool) string {66- // Build preview rectangle if in expansion mode.66+ // Build preview rectangle for visual feedback.6767 var preview *Rectangle6868 var previewClue *Clue69- if m.selectedClue != nil {69+70+ if m.mousePreview != nil {71+ // Mouse drag in progress: use the free-form preview rect.72+ preview = m.mousePreview73+ clues := m.puzzle.CluesInRect(*preview)74+ if len(clues) == 1 {75+ previewClue = clues[0]76+ }77+ } else if m.selectedClue != nil {78+ // Keyboard expansion mode.7079 previewClue = m.puzzle.FindClueByID(*m.selectedClue)7180 if previewClue != nil {7281 r := m.expansion.rect(previewClue)@@ -122,8 +131,11 @@ func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *C
122131 123132 // Preview overlay.124133 inPreview := preview != nil && preview.Contains(x, y)125- if inPreview && previewClue != nil {126- if preview.Area() == previewClue.Value && !m.puzzle.Overlaps(*preview, previewClue.ID) {134+ if inPreview {135+ good := previewClue != nil &&136+ preview.Area() == previewClue.Value &&137+ !m.puzzle.Overlaps(*preview, previewClue.ID)138+ if good {127139 s = s.Background(previewGoodBg).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")})128140 } else {129141 s = s.Background(previewBadBg).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")})@@ -177,12 +189,12 @@ func infoView(p *Puzzle) string {
177189 func statusBarView(selected, showFullHelp bool) string {178190 if selected {179191 if showFullHelp {180- return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")192+ return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel bkspc: delete mouse: drag rect ctrl+n: menu ctrl+r: reset ctrl+h: help")181193 }182- return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel")194+ return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel mouse: drag")183195 }184196 if showFullHelp {185- return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select clue bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")197+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select clue bkspc: delete mouse: click clue & drag ctrl+n: menu ctrl+r: reset ctrl+h: help")186198 }187- return game.StatusBarStyle.Render("enter/space: select clue bkspc: delete")199+ return game.StatusBarStyle.Render("enter/space: select clue bkspc: delete mouse: click & drag")188200 }