- commit
- 428ce97e524cfa92d621feb9a9ce8dddce4653bd
- parent
- 9af24693b428564ef11ba8d7b25c81c18c9f70cf
- author
- Dami <dami.etoile@gmail.com>
| M | wordsearch/ | 248 | ++++++++++++++++++++++++++++++- |
| M | wordsearch/ | 53 | ++++++- |
| M | wordsearch/ | 147 | ++++++++++++++++++ |
@@ -3,6 +3,7 @@ package wordsearch
33 44 import (55 "fmt"6+ "math"67 "strings"78 89 "charm.land/bubbles/v2/key"@@ -17,6 +18,17 @@ const (
1718 startSelected1819 )1920 21+var selectionDirections = []Direction{22+ Right,23+ Down,24+ DownRight,25+ DownLeft,26+ Left,27+ Up,28+ UpRight,29+ UpLeft,30+}31+2032 var _ game.Gamer = Model{}2133 2234 type Model struct {@@ -50,6 +62,15 @@ type Model struct {
5062 lastMouseHit bool5163 }5264 65+type selectionPreview struct {66+ Letters string67+ Direction string68+ Length int69+ Valid bool70+ ExactWord string71+ NearWord string72+}73+5374 func buildFoundCells(width, height int, words []Word) [][]bool {5475 fc := make([][]bool, height)5576 for y := range fc {@@ -146,9 +167,7 @@ func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) {
146167 return147168 }148169 149- // Move cursor to track the drag endpoint; the existing selection150- // rendering uses selectionStart → cursor.151- m.cursor.X, m.cursor.Y = col, row170+ m.setSelectionCursor(game.Cursor{X: col, Y: row})152171 }153172 154173 func (m *Model) handleMouseRelease() {@@ -179,12 +198,69 @@ func (m Model) handleKeyPress(msg tea.KeyPressMsg) (game.Gamer, tea.Cmd) {
179198 case key.Matches(msg, m.keys.Cancel):180199 m.selection = noSelection181200 default:182- m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)201+ m.handleCursorMove(msg)183202 }184203 185204 return m, nil186205 }187206 207+func (m *Model) handleCursorMove(msg tea.KeyPressMsg) {208+ if m.selection == startSelected {209+ if m.moveSelectionCursor(msg) {210+ return211+ }212+ }213+214+ target := m.cursor215+ if !target.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1) {216+ return217+ }218+219+ if m.selection == startSelected {220+ m.setSelectionCursor(target)221+ return222+ }223+224+ m.cursor = target225+}226+227+func (m *Model) moveSelectionCursor(msg tea.KeyPressMsg) bool {228+ dx, dy, valid := lineDirection(m.selectionStart, m.cursor)229+ if !valid {230+ return false231+ }232+233+ moveX, moveY, ok := m.moveDelta(msg)234+ if !ok {235+ return false236+ }237+238+ step := 0239+ switch {240+ case moveX != 0 && dx != 0 && moveX == dx:241+ step = 1242+ case moveY != 0 && dy != 0 && moveY == dy:243+ step = 1244+ case moveX != 0 && dx != 0 && moveX == -dx:245+ step = -1246+ case moveY != 0 && dy != 0 && moveY == -dy:247+ step = -1248+ default:249+ return false250+ }251+252+ next := game.Cursor{253+ X: m.cursor.X + step*dx,254+ Y: m.cursor.Y + step*dy,255+ }256+ if !cursorInBounds(next, m.width-1, m.height-1) {257+ return true258+ }259+260+ m.cursor = next261+ return true262+}263+188264 func (m *Model) handleSelect() {189265 if m.selection == noSelection {190266 // Mark selection start@@ -224,6 +300,10 @@ func (m *Model) validateSelection() {
224300 }225301 }226302 303+func (m *Model) setSelectionCursor(target game.Cursor) {304+ m.cursor = snapSelectionCursor(m.selectionStart, target, m.width-1, m.height-1)305+}306+227307 func (m *Model) checkWin() {228308 allFound := true229309 for _, word := range m.words {@@ -256,6 +336,13 @@ func (m Model) GetDebugInfo() string {
256336 }257337 if m.selection == startSelected {258338 rows = append(rows, [2]string{"Selection Start", fmt.Sprintf("(%d, %d)", m.selectionStart.X, m.selectionStart.Y)})339+ preview := m.currentSelectionPreview()340+ if preview.Valid {341+ rows = append(rows, [2]string{342+ "Selection Preview",343+ fmt.Sprintf("%s (%s, len %d)", preview.Letters, preview.Direction, preview.Length),344+ })345+ }259346 }260347 rows = append(rows,261348 [2]string{"Words Found", fmt.Sprintf("%d/%d", m.countFoundWords(), len(m.words))},@@ -389,3 +476,156 @@ func reverseString(s string) string {
389476 }390477 return string(runes)391478 }479+480+func (m Model) currentSelectionPreview() selectionPreview {481+ if m.selection != startSelected || m.cursor == m.selectionStart {482+ return selectionPreview{}483+ }484+485+ var letters strings.Builder486+ if !walkLine(m.selectionStart, m.cursor, func(x, y int) {487+ letters.WriteRune(m.grid.Get(x, y))488+ }) {489+ return selectionPreview{}490+ }491+492+ word := letters.String()493+ preview := selectionPreview{494+ Letters: word,495+ Direction: directionName(m.selectionStart, m.cursor),496+ Length: len(word),497+ Valid: true,498+ }499+500+ reversed := reverseString(word)501+ bestNearDelta := -1502+ for _, candidate := range m.words {503+ if candidate.Found {504+ continue505+ }506+ switch {507+ case candidate.Text == word || candidate.Text == reversed:508+ preview.ExactWord = candidate.Text509+ return preview510+ case strings.HasPrefix(candidate.Text, word) || strings.HasPrefix(candidate.Text, reversed):511+ delta := len(candidate.Text) - len(word)512+ if bestNearDelta == -1 || delta < bestNearDelta {513+ bestNearDelta = delta514+ preview.NearWord = candidate.Text515+ }516+ }517+ }518+519+ return preview520+}521+522+func directionName(start, end game.Cursor) string {523+ dx, dy, valid := lineDirection(start, end)524+ if !valid {525+ return "pending"526+ }527+528+ switch {529+ case dx == 1 && dy == 0:530+ return "right"531+ case dx == 0 && dy == 1:532+ return "down"533+ case dx == 1 && dy == 1:534+ return "down-right"535+ case dx == -1 && dy == 1:536+ return "down-left"537+ case dx == -1 && dy == 0:538+ return "left"539+ case dx == 0 && dy == -1:540+ return "up"541+ case dx == 1 && dy == -1:542+ return "up-right"543+ case dx == -1 && dy == -1:544+ return "up-left"545+ default:546+ return "pending"547+ }548+}549+550+func (m Model) moveDelta(msg tea.KeyPressMsg) (dx, dy int, ok bool) {551+ switch {552+ case key.Matches(msg, m.keys.Left):553+ return -1, 0, true554+ case key.Matches(msg, m.keys.Right):555+ return 1, 0, true556+ case key.Matches(msg, m.keys.Up):557+ return 0, -1, true558+ case key.Matches(msg, m.keys.Down):559+ return 0, 1, true560+ default:561+ return 0, 0, false562+ }563+}564+565+func snapSelectionCursor(start, target game.Cursor, maxX, maxY int) game.Cursor {566+ if start == target {567+ return start568+ }569+570+ best := start571+ bestDistance := -1572+ bestAlignment := math.Inf(-1)573+ bestSteps := -1574+ targetDX := target.X - start.X575+ targetDY := target.Y - start.Y576+577+ for _, dir := range selectionDirections {578+ dx, dy := dir.Vector()579+ alignment := float64(targetDX*dx+targetDY*dy) / math.Sqrt(float64(dx*dx+dy*dy))580+ for step := 1; step <= maxStepsToEdge(start, dx, dy, maxX, maxY); step++ {581+ candidate := game.Cursor{582+ X: start.X + step*dx,583+ Y: start.Y + step*dy,584+ }585+ distance := squaredDistance(candidate, target)586+ if bestDistance == -1 ||587+ distance < bestDistance ||588+ (distance == bestDistance && alignment > bestAlignment) ||589+ (distance == bestDistance && alignment == bestAlignment && step > bestSteps) {590+ best = candidate591+ bestDistance = distance592+ bestAlignment = alignment593+ bestSteps = step594+ }595+ }596+ }597+598+ return best599+}600+601+func maxStepsToEdge(start game.Cursor, dx, dy, maxX, maxY int) int {602+ steps := maxX + maxY + 2603+ if dx > 0 {604+ steps = min(steps, maxX-start.X)605+ } else if dx < 0 {606+ steps = min(steps, start.X)607+ }608+ if dy > 0 {609+ steps = min(steps, maxY-start.Y)610+ } else if dy < 0 {611+ steps = min(steps, start.Y)612+ }613+ return steps614+}615+616+func squaredDistance(a, b game.Cursor) int {617+ dx := a.X - b.X618+ dy := a.Y - b.Y619+ return dx*dx + dy*dy620+}621+622+func cursorInBounds(cursor game.Cursor, maxX, maxY int) bool {623+ return cursor.X >= 0 && cursor.X <= maxX && cursor.Y >= 0 && cursor.Y <= maxY624+}625+626+func min(a, b int) int {627+ if a < b {628+ return a629+ }630+ return b631+}@@ -41,6 +41,18 @@ func unfoundWordStyle() lipgloss.Style {
4141 Foreground(theme.Current().Info)4242 }4343 44+func previewIdleStyle() lipgloss.Style {45+ return game.StatusBarStyle().Foreground(theme.Current().TextDim)46+}47+48+func previewInfoStyle() lipgloss.Style {49+ return game.StatusBarStyle().Foreground(theme.Current().Info)50+}51+52+func previewSuccessStyle() lipgloss.Style {53+ return game.StatusBarStyle().Foreground(theme.Current().SuccessBorder)54+}55+4456 func gridBorderStyle() lipgloss.Style {4557 p := theme.Current()4658 return lipgloss.NewStyle().@@ -104,9 +116,15 @@ func renderView(m Model) string {
104116 return game.ComposeGameView(title, mainView)105117 }106118 119+ preview := selectionPreviewView(m)107120 status := statusBarView(m.showFullHelp)108121 109- return game.ComposeGameViewRows(title, mainView, game.StableRow(status, statusBarView(false), statusBarView(true)))122+ return game.ComposeGameViewRows(123+ title,124+ mainView,125+ game.StableRow(preview, selectionPreviewView(Model{}), selectionPreviewSolvedView()),126+ game.StableRow(status, statusBarView(false), statusBarView(true)),127+ )110128 }111129 112130 func statusBarView(showFullHelp bool) string {@@ -116,6 +134,39 @@ func statusBarView(showFullHelp bool) string {
116134 return game.StatusBarStyle().Render("enter/space: select bkspc: cancel mouse: click & drag")117135 }118136 137+func selectionPreviewView(m Model) string {138+ preview := m.currentSelectionPreview()139+140+ switch {141+ case m.selection != startSelected:142+ return previewIdleStyle().Render("selection: choose a start cell")143+ case !preview.Valid:144+ return previewIdleStyle().Render("selection: start locked, move to choose an endpoint")145+ default:146+ parts := []string{147+ fmt.Sprintf("trace: %s", preview.Letters),148+ fmt.Sprintf("dir: %s", preview.Direction),149+ fmt.Sprintf("len: %d", preview.Length),150+ }151+152+ switch {153+ case preview.ExactWord != "":154+ parts = append(parts, fmt.Sprintf("match: %s", preview.ExactWord))155+ return previewSuccessStyle().Render(strings.Join(parts, " "))156+ case preview.NearWord != "":157+ parts = append(parts, fmt.Sprintf("near: %s", preview.NearWord))158+ return previewInfoStyle().Render(strings.Join(parts, " "))159+ default:160+ parts = append(parts, "line: ready")161+ return previewIdleStyle().Render(strings.Join(parts, " "))162+ }163+ }164+}165+166+func selectionPreviewSolvedView() string {167+ return previewSuccessStyle().Render("trace: PUZZLETEA dir: down-right len: 10 match: PUZZLETEA")168+}169+119170 func renderGrid(m Model) string {120171 var rows []string121172 @@ -146,6 +146,62 @@ func TestBackspaceCancelsSelection(t *testing.T) {
146146 }147147 }148148 149+func TestSelectionMoveSnapsToNearestRay(t *testing.T) {150+ m := Model{151+ width: 5,152+ height: 5,153+ grid: createEmptyGrid(5, 5),154+ cursor: game.Cursor{X: 1, Y: 1},155+ keys: DefaultKeyMap,156+ foundCells: buildFoundCells(5, 5, nil),157+ }158+159+ m.handleSelect()160+161+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})162+ got := next.(Model)163+ if got.cursor != (game.Cursor{X: 2, Y: 1}) {164+ t.Fatalf("after right, cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)165+ }166+167+ next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyDown})168+ got = next.(Model)169+ if got.cursor != (game.Cursor{X: 2, Y: 2}) {170+ t.Fatalf("after down, cursor = (%d,%d), want (2,2)", got.cursor.X, got.cursor.Y)171+ }172+}173+174+func TestSelectionMoveShrinksDiagonalWithArrowKeys(t *testing.T) {175+ m := Model{176+ width: 5,177+ height: 5,178+ grid: createEmptyGrid(5, 5),179+ cursor: game.Cursor{X: 3, Y: 3},180+ selection: startSelected,181+ selectionStart: game.Cursor{X: 1, Y: 1},182+ keys: DefaultKeyMap,183+ foundCells: buildFoundCells(5, 5, nil),184+ }185+186+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyLeft})187+ got := next.(Model)188+ if got.cursor != (game.Cursor{X: 2, Y: 2}) {189+ t.Fatalf("after left, cursor = (%d,%d), want (2,2)", got.cursor.X, got.cursor.Y)190+ }191+192+ next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyUp})193+ got = next.(Model)194+ if got.cursor != (game.Cursor{X: 1, Y: 1}) {195+ t.Fatalf("after up, cursor = (%d,%d), want (1,1)", got.cursor.X, got.cursor.Y)196+ }197+198+ next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyDown})199+ got = next.(Model)200+ if got.cursor != (game.Cursor{X: 1, Y: 2}) {201+ t.Fatalf("after down from collapsed selection, cursor = (%d,%d), want (1,2)", got.cursor.X, got.cursor.Y)202+ }203+}204+149205 // --- Grid operations (P1) ---150206 151207 func TestGridGetSet(t *testing.T) {@@ -390,6 +446,97 @@ func TestWalkLine(t *testing.T) {
390446 }391447 }392448 449+func TestSnapSelectionCursor(t *testing.T) {450+ tests := []struct {451+ name string452+ start game.Cursor453+ target game.Cursor454+ maxX int455+ maxY int456+ want game.Cursor457+ }{458+ {459+ name: "keeps exact horizontal target",460+ start: game.Cursor{X: 2, Y: 2},461+ target: game.Cursor{X: 4, Y: 2},462+ maxX: 4,463+ maxY: 4,464+ want: game.Cursor{X: 4, Y: 2},465+ },466+ {467+ name: "snaps off-axis target to diagonal",468+ start: game.Cursor{X: 2, Y: 2},469+ target: game.Cursor{X: 3, Y: 4},470+ maxX: 4,471+ maxY: 4,472+ want: game.Cursor{X: 4, Y: 4},473+ },474+ {475+ name: "snaps closer to vertical than diagonal",476+ start: game.Cursor{X: 2, Y: 2},477+ target: game.Cursor{X: 2, Y: 4},478+ maxX: 4,479+ maxY: 4,480+ want: game.Cursor{X: 2, Y: 4},481+ },482+ }483+484+ for _, tt := range tests {485+ t.Run(tt.name, func(t *testing.T) {486+ got := snapSelectionCursor(tt.start, tt.target, tt.maxX, tt.maxY)487+ if got != tt.want {488+ t.Fatalf("snapSelectionCursor(%v, %v) = %v, want %v", tt.start, tt.target, got, tt.want)489+ }490+ })491+ }492+}493+494+func TestCurrentSelectionPreview(t *testing.T) {495+ m := Model{496+ width: 5,497+ height: 5,498+ grid: newGrid(state(499+ "HELLO\n" +500+ "AAAAA\n" +501+ "BBBBB\n" +502+ "CCCCC\n" +503+ "DDDDD",504+ )),505+ words: []Word{506+ {Text: "HELLO", Start: Position{0, 0}, End: Position{4, 0}, Direction: Right},507+ {Text: "HELL", Start: Position{0, 1}, End: Position{3, 1}, Direction: Right, Found: true},508+ },509+ cursor: game.Cursor{X: 2, Y: 0},510+ selection: startSelected,511+ selectionStart: game.Cursor{X: 0, Y: 0},512+ keys: DefaultKeyMap,513+ foundCells: buildFoundCells(5, 5, nil),514+ }515+516+ preview := m.currentSelectionPreview()517+ if !preview.Valid {518+ t.Fatal("expected preview to be valid")519+ }520+ if preview.Letters != "HEL" {521+ t.Fatalf("Letters = %q, want HEL", preview.Letters)522+ }523+ if preview.Direction != "right" {524+ t.Fatalf("Direction = %q, want right", preview.Direction)525+ }526+ if preview.NearWord != "HELLO" {527+ t.Fatalf("NearWord = %q, want HELLO", preview.NearWord)528+ }529+ if preview.ExactWord != "" {530+ t.Fatalf("ExactWord = %q, want empty", preview.ExactWord)531+ }532+533+ m.cursor = game.Cursor{X: 4, Y: 0}534+ preview = m.currentSelectionPreview()535+ if preview.ExactWord != "HELLO" {536+ t.Fatalf("ExactWord = %q, want HELLO", preview.ExactWord)537+ }538+}539+393540 // --- reverseString (P1) ---394541 395542 func TestReverseString(t *testing.T) {