clone

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

nonogram ux improvement for paint mode to keep orthogonal movement going.

Dami committed a1688aa
commit
a1688aa80314e333a04b1243c93155b76fd7d40a
parent
b9db185a6c9bf337ded7c7b19b3a1ea36438119a
author
Dami <dami.etoile@gmail.com>
21 files changed, +409 -15
Mapp/help_layout_test.go24+++
Mapp/update.go13+-
Mnonogram/Model.go41++++-
Mnonogram/dynamic_grid_test.go39+++++
Mnonogram/mouse.go42+++++
Mnonogram/nonogram_test.go256++++++++++++++++++++++++++++++
Mnonogram/style.go9+-
Mvhs/fillomino.gifbin
Mvhs/hashiwokakero.gifbin
Mvhs/help.gifbin
Mvhs/hitori.gifbin
Mvhs/lightsout.gifbin
Mvhs/menu.gifbin
Mvhs/nonogram.gifbin
Mvhs/nurikabe.gifbin
Mvhs/rippleeffect.gifbin
Mvhs/shikaku.gifbin
Mvhs/stats.gifbin
Mvhs/sudoku.gifbin
Mvhs/takuzu.gifbin
Mvhs/wordsearch.gifbin
M app/help_layout_test.go +24 -0
M app/update.go +11 -2
@@ -47,6 +47,13 @@ func helpViewportSize(width, height int) (int, int) {
4747 return contentWidth, contentHeight4848 }4949 50+func helpSelectListSize(width, height int, l list.Model) (int, int) {51+ contentWidth, contentHeight := helpViewportSize(width, height)52+ listWidth := min(contentWidth, 64)53+ listHeight := min(contentHeight, ui.ListHeight(l))54+ return listWidth, listHeight55+}56+5057 func statsViewportSize(width, height int, cards []stats.Card) (int, int) {5158 contentWidth, _ := helpViewportSize(width, height)5259 panelHeight := max(height-(helpPanelInsetY*2), 1)
@@ -280,7 +287,8 @@ func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {
280287 m = m.refreshWeeklyBrowser()281288 }282289 if m.state == helpSelectView {283- m.nav.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.helpSelectList)))290+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)291+ m.nav.helpSelectList.SetSize(listWidth, listHeight)284292 }285293 if m.state == helpDetailView {286294 m = m.updateHelpDetailViewport()
@@ -508,7 +516,8 @@ func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {
508516 return m.handleThemeEnter()509517 case "Guides":510518 m.nav.helpSelectList = ui.InitList(gameCategoryItems, "How to Play")511- m.nav.helpSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.helpSelectList)))519+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)520+ m.nav.helpSelectList.SetSize(listWidth, listHeight)512521 m.state = helpSelectView513522 }514523 return m, nil
M nonogram/Model.go +35 -6
@@ -18,6 +18,14 @@ const (
1818 emptyTile = ' '1919 )2020 21+type dragAxis uint822+23+const (24+ dragAxisNone dragAxis = iota25+ dragAxisHorizontal26+ dragAxisVertical27+)28+2129 var _ game.Gamer = Model{}2230 2331 type Model struct {
@@ -37,7 +45,10 @@ type Model struct {
3745 supportsKeyRelease bool // set from tea.KeyboardEnhancementsMsg3846 3947 // Mouse drag support.40- dragging rune // 0 = not dragging, filledTile or markedTile while mouse held48+ dragging rune // 0 = not dragging, filledTile or markedTile while mouse held49+ dragStartCol int50+ dragStartRow int51+ dragAxis dragAxis4152 4253 // Screen geometry for mouse hit-testing.4354 termWidth, termHeight int
@@ -148,10 +159,10 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
148159 switch msg.Button {149160 case tea.MouseLeft:150161 m.toggleTile(filledTile)151- m.dragging = filledTile162+ m.startDrag(filledTile, col, row)152163 case tea.MouseRight:153164 m.toggleTile(markedTile)154- m.dragging = markedTile165+ m.startDrag(markedTile, col, row)155166 }156167 157168 case tea.MouseMotionMsg:
@@ -162,6 +173,10 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
162173 if !ok {163174 break164175 }176+ col, row, ok = m.lockedDragTarget(col, row)177+ if !ok {178+ break179+ }165180 if col == m.cursor.X && row == m.cursor.Y {166181 break167182 }
@@ -170,7 +185,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
170185 m.updateTile(m.dragging)171186 172187 case tea.MouseReleaseMsg:173- m.dragging = 0188+ m.clearDrag()174189 }175190 176191 m.updateKeyBindings()
@@ -211,7 +226,7 @@ func (m Model) GetDebugInfo() string {
211226 {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},212227 {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},213228 {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hitStr)},214- {"Paint/Drag", fmt.Sprintf("brush=%d drag=%d keyrel=%v", m.paintBrush, m.dragging, m.supportsKeyRelease)},229+ {"Paint/Drag", fmt.Sprintf("brush=%d drag=%d axis=%d start=(%d, %d) keyrel=%v", m.paintBrush, m.dragging, m.dragAxis, m.dragStartCol, m.dragStartRow, m.supportsKeyRelease)},215230 })216231 217232 s += tomoDebugTable("Row Tomography", "Row", m.rowHints, m.currentHints.rows)
@@ -269,7 +284,7 @@ func (m Model) Reset() game.Gamer {
269284 m.solved = false270285 m.cursor = game.Cursor{}271286 m.paintBrush = 0272- m.dragging = 0287+ m.clearDrag()273288 m.originValid = false274289 return m275290 }
@@ -294,3 +309,17 @@ func (m *Model) toggleTile(target rune) {
294309 m.updateTile(target)295310 }296311 }312+313+func (m *Model) startDrag(brush rune, col, row int) {314+ m.dragging = brush315+ m.dragStartCol = col316+ m.dragStartRow = row317+ m.dragAxis = dragAxisNone318+}319+320+func (m *Model) clearDrag() {321+ m.dragging = 0322+ m.dragStartCol = 0323+ m.dragStartRow = 0324+ m.dragAxis = dragAxisNone325+}
M nonogram/dynamic_grid_test.go +39 -0
@@ -110,6 +110,39 @@ func TestSeparatorBridgeFillHighlightsOnlySpacerEdgesOnAxis(t *testing.T) {
110110 }111111 }112112 113+func TestTileViewUsesSharedCrosshairSurfaceTint(t *testing.T) {114+ p := theme.Current()115+116+ gotFilled := tileView(filledTile, false, true, false, false)117+ wantFilled := filledStyle().118+ Background(p.Surface).119+ Width(cellWidth).120+ AlignHorizontal(lipgloss.Center).121+ Render(renderRuneMap[filledTile])122+ if gotFilled != wantFilled {123+ t.Fatalf("filled crosshair tileView() = %q, want %q", gotFilled, wantFilled)124+ }125+126+ gotEmpty := tileView(emptyTile, false, false, true, false)127+ wantEmpty := emptyStyle().128+ Background(p.Surface).129+ Width(cellWidth).130+ AlignHorizontal(lipgloss.Center).131+ Render(renderRuneMap[emptyTile])132+ if gotEmpty != wantEmpty {133+ t.Fatalf("empty crosshair tileView() = %q, want %q", gotEmpty, wantEmpty)134+ }135+136+ gotCursor := tileView(filledTile, true, true, true, false)137+ wantCursor := game.CursorStyle().138+ Width(cellWidth).139+ AlignHorizontal(lipgloss.Center).140+ Render(game.CursorLeft + string([]rune(renderRuneMap[filledTile])[1]) + game.CursorRight)141+ if gotCursor != wantCursor {142+ t.Fatalf("cursor tileView() = %q, want %q", gotCursor, wantCursor)143+ }144+}145+113146 func TestBuildBoardBlockPreservesHintBandLayout(t *testing.T) {114147 m := Model{115148 width: 5,
@@ -134,6 +167,12 @@ func TestBuildBoardBlockPreservesHintBandLayout(t *testing.T) {
134167 if got := []rune(lines[layout.HintHeight])[layout.HintWidth]; got != '┌' {135168 t.Fatalf("grid top border should start after hint band, got %q", got)136169 }170+ if strings.TrimSpace(lines[layout.HintHeight][:layout.HintWidth]) != "" {171+ t.Fatalf("row hints should not occupy the grid border row, got %q", lines[layout.HintHeight][:layout.HintWidth])172+ }173+ if strings.TrimSpace(lines[layout.HintHeight+1][:layout.HintWidth]) == "" {174+ t.Fatalf("row hints should begin on the first grid content row, got %q", lines[layout.HintHeight+1][:layout.HintWidth])175+ }137176 if got := []rune(lines[layout.HintHeight+1])[layout.HintWidth]; got != '│' {138177 t.Fatalf("grid content should remain offset after hint band, got %q", got)139178 }
M nonogram/mouse.go +42 -0
M nonogram/nonogram_test.go +256 -0
@@ -8,6 +8,7 @@ import (
88 "testing"99 "time"1010 11+ tea "charm.land/bubbletea/v2"1112 "github.com/FelineStateMachine/puzzletea/game"1213 )1314
@@ -1054,6 +1055,261 @@ func TestReset(t *testing.T) {
10541055 }10551056 }10561057 1058+func buildMouseTestModel(t *testing.T, width, height int) Model {1059+ t.Helper()1060+1061+ mode := NonogramMode{1062+ BaseMode: game.NewBaseMode("Test", "grid"),1063+ Width: width,1064+ Height: height,1065+ }1066+ hints := Hints{1067+ rows: repeatedHints(height, []int{1}),1068+ cols: repeatedHints(width, []int{1}),1069+ }1070+ g, err := New(mode, hints)1071+ if err != nil {1072+ t.Fatal(err)1073+ }1074+ m := g.(Model)1075+ m.termWidth = 1201076+ m.termHeight = 401077+ return m1078+}1079+1080+func nonogramCellScreenCoords(t *testing.T, m *Model, wantCol, wantRow int) (int, int) {1081+ t.Helper()1082+1083+ ox, oy := m.gridOrigin()1084+ maxX := ox + m.width*(cellWidth+1) + 41085+ maxY := oy + m.height*2 + 41086+ for y := oy; y < maxY; y++ {1087+ for x := ox; x < maxX; x++ {1088+ col, row, ok := m.screenToGrid(x, y, false)1089+ if ok && col == wantCol && row == wantRow {1090+ return x, y1091+ }1092+ }1093+ }1094+1095+ t.Fatalf("screen coordinates not found for cell (%d,%d)", wantCol, wantRow)1096+ return 0, 01097+}1098+1099+func updateNonogramModel(t *testing.T, m Model, msg tea.Msg) Model {1100+ t.Helper()1101+1102+ next, _ := m.Update(msg)1103+ got, ok := next.(Model)1104+ if !ok {1105+ t.Fatal("expected nonogram model update to return Model")1106+ }1107+ return got1108+}1109+1110+func TestMouseDragAxisLockHorizontal(t *testing.T) {1111+ m := buildMouseTestModel(t, 5, 5)1112+ startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1113+ nextX, nextY := nonogramCellScreenCoords(t, &m, 2, 1)1114+ jitterX, jitterY := nonogramCellScreenCoords(t, &m, 3, 2)1115+1116+ m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1117+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: nextX, Y: nextY, Button: tea.MouseLeft})1118+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: jitterX, Y: jitterY, Button: tea.MouseLeft})1119+1120+ if m.dragAxis != dragAxisHorizontal {1121+ t.Fatalf("dragAxis = %v, want horizontal", m.dragAxis)1122+ }1123+ if m.cursor != (game.Cursor{X: 3, Y: 1}) {1124+ t.Fatalf("cursor = %v, want (3,1)", m.cursor)1125+ }1126+ if got := m.grid[1][1]; got != filledTile {1127+ t.Fatalf("start cell = %q, want filled", got)1128+ }1129+ if got := m.grid[1][2]; got != filledTile {1130+ t.Fatalf("locked row cell = %q, want filled", got)1131+ }1132+ if got := m.grid[1][3]; got != filledTile {1133+ t.Fatalf("jitter target cell = %q, want filled", got)1134+ }1135+ if got := m.grid[2][3]; got != emptyTile {1136+ t.Fatalf("off-axis cell = %q, want empty", got)1137+ }1138+}1139+1140+func TestMouseDragAxisLockVertical(t *testing.T) {1141+ m := buildMouseTestModel(t, 5, 5)1142+ startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1143+ nextX, nextY := nonogramCellScreenCoords(t, &m, 1, 2)1144+ jitterX, jitterY := nonogramCellScreenCoords(t, &m, 2, 3)1145+1146+ m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1147+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: nextX, Y: nextY, Button: tea.MouseLeft})1148+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: jitterX, Y: jitterY, Button: tea.MouseLeft})1149+1150+ if m.dragAxis != dragAxisVertical {1151+ t.Fatalf("dragAxis = %v, want vertical", m.dragAxis)1152+ }1153+ if m.cursor != (game.Cursor{X: 1, Y: 3}) {1154+ t.Fatalf("cursor = %v, want (1,3)", m.cursor)1155+ }1156+ if got := m.grid[1][1]; got != filledTile {1157+ t.Fatalf("start cell = %q, want filled", got)1158+ }1159+ if got := m.grid[2][1]; got != filledTile {1160+ t.Fatalf("locked column cell = %q, want filled", got)1161+ }1162+ if got := m.grid[3][1]; got != filledTile {1163+ t.Fatalf("jitter target cell = %q, want filled", got)1164+ }1165+ if got := m.grid[3][2]; got != emptyTile {1166+ t.Fatalf("off-axis cell = %q, want empty", got)1167+ }1168+}1169+1170+func TestMouseDragAxisLockLargestDelta(t *testing.T) {1171+ m := buildMouseTestModel(t, 5, 5)1172+ startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1173+ diagX, diagY := nonogramCellScreenCoords(t, &m, 3, 2)1174+1175+ m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1176+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: diagX, Y: diagY, Button: tea.MouseLeft})1177+1178+ if m.dragAxis != dragAxisHorizontal {1179+ t.Fatalf("dragAxis = %v, want horizontal", m.dragAxis)1180+ }1181+ if m.cursor != (game.Cursor{X: 3, Y: 1}) {1182+ t.Fatalf("cursor = %v, want (3,1)", m.cursor)1183+ }1184+ if got := m.grid[2][3]; got != emptyTile {1185+ t.Fatalf("off-axis diagonal cell = %q, want empty", got)1186+ }1187+}1188+1189+func TestMouseDragAxisLockTieBreaksLater(t *testing.T) {1190+ m := buildMouseTestModel(t, 5, 5)1191+ startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1192+ tieX, tieY := nonogramCellScreenCoords(t, &m, 2, 2)1193+ breakX, breakY := nonogramCellScreenCoords(t, &m, 3, 2)1194+1195+ m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1196+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: tieX, Y: tieY, Button: tea.MouseLeft})1197+1198+ if m.dragAxis != dragAxisNone {1199+ t.Fatalf("dragAxis after tie = %v, want none", m.dragAxis)1200+ }1201+ if m.cursor != (game.Cursor{X: 1, Y: 1}) {1202+ t.Fatalf("cursor after tie = %v, want anchor", m.cursor)1203+ }1204+ if got := m.grid[2][2]; got != emptyTile {1205+ t.Fatalf("tie cell = %q, want empty", got)1206+ }1207+1208+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: breakX, Y: breakY, Button: tea.MouseLeft})1209+1210+ if m.dragAxis != dragAxisHorizontal {1211+ t.Fatalf("dragAxis after tie-break = %v, want horizontal", m.dragAxis)1212+ }1213+ if m.cursor != (game.Cursor{X: 3, Y: 1}) {1214+ t.Fatalf("cursor after tie-break = %v, want (3,1)", m.cursor)1215+ }1216+ if got := m.grid[1][3]; got != filledTile {1217+ t.Fatalf("tie-break cell = %q, want filled", got)1218+ }1219+}1220+1221+func TestMouseDragSeparatorSnapAfterLock(t *testing.T) {1222+ m := buildMouseTestModel(t, 10, 10)1223+ startX, startY := nonogramCellScreenCoords(t, &m, 4, 0)1224+ ox, oy := m.gridOrigin()1225+ separatorX := ox + cellWidth*51226+ jitterX, jitterY := nonogramCellScreenCoords(t, &m, 6, 1)1227+1228+ m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1229+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: separatorX, Y: oy, Button: tea.MouseLeft})1230+1231+ if m.dragAxis != dragAxisHorizontal {1232+ t.Fatalf("dragAxis at separator = %v, want horizontal", m.dragAxis)1233+ }1234+ if m.cursor != (game.Cursor{X: 5, Y: 0}) {1235+ t.Fatalf("cursor at separator = %v, want (5,0)", m.cursor)1236+ }1237+1238+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: jitterX, Y: jitterY, Button: tea.MouseLeft})1239+1240+ if m.cursor != (game.Cursor{X: 6, Y: 0}) {1241+ t.Fatalf("cursor after separator jitter = %v, want (6,0)", m.cursor)1242+ }1243+ if got := m.grid[0][6]; got != filledTile {1244+ t.Fatalf("separator clamped cell = %q, want filled", got)1245+ }1246+ if got := m.grid[1][6]; got != emptyTile {1247+ t.Fatalf("off-axis separator cell = %q, want empty", got)1248+ }1249+}1250+1251+func TestMouseClickWithoutMotionTogglesOnlyStartCell(t *testing.T) {1252+ m := buildMouseTestModel(t, 5, 5)1253+ startX, startY := nonogramCellScreenCoords(t, &m, 2, 2)1254+1255+ m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1256+ m = updateNonogramModel(t, m, tea.MouseReleaseMsg{X: startX, Y: startY, Button: tea.MouseLeft})1257+1258+ if got := m.grid[2][2]; got != filledTile {1259+ t.Fatalf("start cell = %q, want filled", got)1260+ }1261+ for y := range m.height {1262+ for x := range m.width {1263+ if x == 2 && y == 2 {1264+ continue1265+ }1266+ if got := m.grid[y][x]; got != emptyTile {1267+ t.Fatalf("grid[%d][%d] = %q, want empty", y, x, got)1268+ }1269+ }1270+ }1271+}1272+1273+func TestMouseReleaseClearsAxisLockState(t *testing.T) {1274+ m := buildMouseTestModel(t, 5, 5)1275+ startX, startY := nonogramCellScreenCoords(t, &m, 1, 1)1276+ nextX, nextY := nonogramCellScreenCoords(t, &m, 2, 1)1277+1278+ m = updateNonogramModel(t, m, tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})1279+ m = updateNonogramModel(t, m, tea.MouseMotionMsg{X: nextX, Y: nextY, Button: tea.MouseLeft})1280+ m = updateNonogramModel(t, m, tea.MouseReleaseMsg{X: nextX, Y: nextY, Button: tea.MouseLeft})1281+1282+ if m.dragging != 0 {1283+ t.Fatalf("dragging = %d, want 0", m.dragging)1284+ }1285+ if m.dragAxis != dragAxisNone {1286+ t.Fatalf("dragAxis = %v, want none", m.dragAxis)1287+ }1288+ if m.dragStartCol != 0 || m.dragStartRow != 0 {1289+ t.Fatalf("drag start = (%d,%d), want cleared", m.dragStartCol, m.dragStartRow)1290+ }1291+}1292+1293+func TestResetClearsAxisLockState(t *testing.T) {1294+ m := buildMouseTestModel(t, 5, 5)1295+ m.dragging = filledTile1296+ m.dragStartCol = 21297+ m.dragStartRow = 31298+ m.dragAxis = dragAxisVertical1299+1300+ reset := m.Reset().(Model)1301+1302+ if reset.dragging != 0 {1303+ t.Fatalf("dragging = %d, want 0", reset.dragging)1304+ }1305+ if reset.dragAxis != dragAxisNone {1306+ t.Fatalf("dragAxis = %v, want none", reset.dragAxis)1307+ }1308+ if reset.dragStartCol != 0 || reset.dragStartRow != 0 {1309+ t.Fatalf("drag start = (%d,%d), want cleared", reset.dragStartCol, reset.dragStartRow)1310+ }1311+}1312+10571313 // --- screenToGrid (P0) ---10581314 10591315 func TestScreenToGrid(t *testing.T) {
M nonogram/style.go +2 -7
M vhs/fillomino.gif +0 -0

Binary file.

M vhs/hashiwokakero.gif +0 -0

Binary file.

M vhs/help.gif +0 -0

Binary file.

M vhs/hitori.gif +0 -0

Binary file.

M vhs/lightsout.gif +0 -0

Binary file.

M vhs/menu.gif +0 -0

Binary file.

M vhs/nonogram.gif +0 -0

Binary file.

M vhs/nurikabe.gif +0 -0

Binary file.

M vhs/rippleeffect.gif +0 -0

Binary file.

M vhs/shikaku.gif +0 -0

Binary file.

M vhs/stats.gif +0 -0

Binary file.

M vhs/sudoku.gif +0 -0

Binary file.

M vhs/takuzu.gif +0 -0

Binary file.

M vhs/wordsearch.gif +0 -0

Binary file.