clone

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

word search ui pizazzin

Dami committed 428ce97
commit
428ce97e524cfa92d621feb9a9ce8dddce4653bd
parent
9af24693b428564ef11ba8d7b25c81c18c9f70cf
author
Dami <dami.etoile@gmail.com>
3 files changed, +443 -5
Mwordsearch/Model.go248++++++++++++++++++++++++++++++-
Mwordsearch/style.go53++++++-
Mwordsearch/wordsearch_test.go147++++++++++++++++++
M wordsearch/Model.go +244 -4
@@ -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+}
M wordsearch/style.go +52 -1
@@ -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
M wordsearch/wordsearch_test.go +147 -0
@@ -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) {