clone

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

feat(nonogram): add mouse click/drag and hold-to-paint support

Dami committed 68ab28e
Mouse support (uses bubbletea v2 declarative MouseModeCellMotion):
- Left click toggles fill (filled <-> empty) and moves cursor
- Right click toggles mark (marked <-> empty) and moves cursor
- Click and drag to paint multiple cells with left or right button
- Release clears drag state

Hold-to-paint (uses bubbletea v2 KeyboardEnhancements):
- Hold z while pressing arrow keys to fill cells as cursor moves
- Hold x while pressing arrow keys to mark cells as cursor moves
- Release z/x to stop painting
- Graceful degradation: on terminals without keyboard enhancements,
  z/x continue to work as single-press operations (no change)

Implementation details:
- New mouse.go with screenToGrid() hit-test helper that maps terminal
  coordinates to grid cells, accounting for spacer separators
- gridOrigin() computes the grid's screen position from layout geometry
  and terminal centering offsets
- Unit tests for localToCellX, localToCellY, and screenToGrid
- Root view.go enables MouseModeCellMotion and ReportEventTypes
  during gameView state
- Update handler split into KeyPressMsg + KeyReleaseMsg (was KeyMsg)
commit
68ab28e0bdd35083d52a73daf2391fb953fdc418
parent
345254c08b80aaf6ab9c6520af7951f9f5ff5f85
author
Dami <dami.etoile@gmail.com>
5 files changed, +373 -4
Mnonogram/Model.go89+++++++++++++++-
Anonogram/mouse.go111++++++++++++++++++++
Mnonogram/nonogram_test.go169++++++++++++++++++++++++++++++
Mnonogram/style.go4+-
Mview.go4+
M nonogram/Model.go +87 -2
@@ -30,6 +30,16 @@ type Model struct {
3030 showFullHelp bool3131 currentHints Hints // cached tomography of the current grid3232 solved bool // cached solved state33+34+ // Hold-to-paint support (requires keyboard enhancements).35+ paintBrush rune // 0 = inactive, filledTile or markedTile when painting36+ supportsKeyRelease bool // set from tea.KeyboardEnhancementsMsg37+38+ // Mouse drag support.39+ dragging rune // 0 = not dragging, filledTile or markedTile while mouse held40+41+ // Screen geometry for mouse hit-testing.42+ termWidth, termHeight int3343 }3444 3545 func New(mode NonogramMode, hints Hints) (game.Gamer, error) {
@@ -66,17 +76,81 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6676 switch msg := msg.(type) {6777 case game.HelpToggleMsg:6878 m.showFullHelp = msg.Show69- case tea.KeyMsg:79+80+ case tea.KeyboardEnhancementsMsg:81+ m.supportsKeyRelease = msg.SupportsEventTypes()82+83+ case tea.WindowSizeMsg:84+ m.termWidth = msg.Width85+ m.termHeight = msg.Height86+87+ case tea.KeyPressMsg:88+ if m.solved {89+ break90+ }7091 switch {7192 case key.Matches(msg, m.keys.FillTile):7293 m.updateTile(filledTile)94+ if m.supportsKeyRelease {95+ m.paintBrush = filledTile96+ }7397 case key.Matches(msg, m.keys.MarkTile):7498 m.updateTile(markedTile)99+ if m.supportsKeyRelease {100+ m.paintBrush = markedTile101+ }75102 case key.Matches(msg, m.keys.ClearTile):76103 m.updateTile(emptyTile)104+ m.paintBrush = 0105+ default:106+ moved := m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)107+ if moved && m.paintBrush != 0 {108+ m.updateTile(m.paintBrush)109+ }110+ }111+112+ case tea.KeyReleaseMsg:113+ if key.Matches(msg, m.keys.FillTile) || key.Matches(msg, m.keys.MarkTile) {114+ m.paintBrush = 0115+ }116+117+ case tea.MouseClickMsg:118+ if m.solved {119+ break77120 }78- m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)121+ col, row, ok := m.screenToGrid(msg.X, msg.Y)122+ if !ok {123+ break124+ }125+ m.cursor.X, m.cursor.Y = col, row126+ switch msg.Button {127+ case tea.MouseLeft:128+ m.toggleTile(filledTile)129+ m.dragging = filledTile130+ case tea.MouseRight:131+ m.toggleTile(markedTile)132+ m.dragging = markedTile133+ }134+135+ case tea.MouseMotionMsg:136+ if m.solved || m.dragging == 0 {137+ break138+ }139+ col, row, ok := m.screenToGrid(msg.X, msg.Y)140+ if !ok {141+ break142+ }143+ if col == m.cursor.X && row == m.cursor.Y {144+ break145+ }146+ m.cursor.X, m.cursor.Y = col, row147+ // While dragging, always set (don't toggle) to maintain consistency.148+ m.updateTile(m.dragging)149+150+ case tea.MouseReleaseMsg:151+ m.dragging = 079152 }153+80154 m.updateKeyBindings()81155 return m, nil82156 }
@@ -174,3 +248,14 @@ func (m *Model) updateTile(r rune) {
174248 m.currentHints = generateTomography(m.grid)175249 m.solved = m.currentHints.rows.equal(m.rowHints) && m.currentHints.cols.equal(m.colHints)176250 }251+252+// toggleTile toggles a cell between the given target state and empty.253+// If the cell already has the target value, it becomes empty; otherwise254+// it becomes the target.255+func (m *Model) toggleTile(target rune) {256+ if m.grid[m.cursor.Y][m.cursor.X] == target {257+ m.updateTile(emptyTile)258+ } else {259+ m.updateTile(target)260+ }261+}
A nonogram/mouse.go +111 -0
@@ -0,0 +1,111 @@
1+package nonogram2+3+// screenToGrid converts terminal screen coordinates to grid cell coordinates.4+// Returns (col, row, ok) where ok is false if the click landed outside the grid5+// or on a separator.6+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {7+ ox, oy := m.gridOrigin()8+ lx := screenX - ox9+ ly := screenY - oy10+ if lx < 0 || ly < 0 {11+ return 0, 0, false12+ }13+14+ col = localToCellX(lx, m.width)15+ row = localToCellY(ly, m.height)16+ if col < 0 || col >= m.width || row < 0 || row >= m.height {17+ return 0, 0, false18+ }19+20+ return col, row, true21+}22+23+// localToCellX converts a local horizontal offset within the grid area to a24+// column index, accounting for spacer separators every spacerEvery cells.25+// Each cell is cellWidth (3) characters wide; separators are 1 char wide.26+// Returns -1 if the position falls on a separator or out of bounds.27+func localToCellX(pos, count int) int {28+ return localToCell(pos, count, cellWidth)29+}30+31+// localToCellY converts a local vertical offset within the grid area to a32+// row index. Each row is 1 terminal line tall; separators are 1 line tall.33+// Returns -1 if the position falls on a separator or out of bounds.34+func localToCellY(pos, count int) int {35+ return localToCell(pos, count, 1)36+}37+38+// localToCell is the generic conversion for both axes.39+// unitWidth is the visual width of one cell (cellWidth for X, 1 for Y).40+func localToCell(pos, count, unitWidth int) int {41+ if count <= spacerEvery {42+ cell := pos / unitWidth43+ if cell >= count {44+ return -145+ }46+ return cell47+ }48+49+ blockWidth := spacerEvery*unitWidth + 150+ block := pos / blockWidth51+ rem := pos % blockWidth52+53+ cellInBlock := rem / unitWidth54+ if cellInBlock >= spacerEvery {55+ return -156+ }57+58+ cell := block*spacerEvery + cellInBlock59+ if cell >= count {60+ return -161+ }62+ return cell63+}64+65+// gridOrigin computes the screen position of the top-left corner of the66+// first grid cell. This depends on the hint areas, title bar, centering67+// offsets, and the status bar.68+func (m *Model) gridOrigin() (x, y int) {69+ rowHintWidth := m.rowHints.RequiredLen() * cellWidth70+ colHintHeight := m.colHints.RequiredLen()71+72+ // Grid content dimensions (the entire View output of the nonogram).73+ gridRenderWidth := rowHintWidth + gridPixelWidth(m.width)74+ gridRenderHeight := colHintHeight + gridPixelHeight(m.height)75+76+ // Title bar is 1 line of text. The overall View joins:77+ // title (1 line) + grid block + status bar (2 lines with MarginTop 1).78+ titleHeight := 179+ statusHeight := 2 // 1 line text + 1 line margin80+ totalContentHeight := titleHeight + gridRenderHeight + statusHeight81+82+ // The content width is the max of the title width and grid block width.83+ // Title is short, so grid block dominates.84+ totalContentWidth := gridRenderWidth85+86+ // CenterView uses lipgloss.Place which centers within termWidth x termHeight.87+ centerOffsetX := max((m.termWidth-totalContentWidth)/2, 0)88+ centerOffsetY := max((m.termHeight-totalContentHeight)/2, 0)89+90+ x = centerOffsetX + rowHintWidth91+ y = centerOffsetY + titleHeight + colHintHeight92+ return x, y93+}94+95+// gridPixelWidth returns the terminal character width of the grid area.96+func gridPixelWidth(w int) int {97+ pw := w * cellWidth98+ if w > spacerEvery {99+ pw += (w - 1) / spacerEvery100+ }101+ return pw102+}103+104+// gridPixelHeight returns the terminal line height of the grid area.105+func gridPixelHeight(h int) int {106+ ph := h107+ if h > spacerEvery {108+ ph += (h - 1) / spacerEvery109+ }110+ return ph111+}
M nonogram/nonogram_test.go +169 -0
@@ -983,3 +983,172 @@ func TestReset(t *testing.T) {
983983 t.Error("solved should be false after reset")984984 }985985 }986+987+// --- localToCell (P0) ---988+989+func TestLocalToCellX(t *testing.T) {990+ t.Run("small grid no spacers", func(t *testing.T) {991+ // 5 cells, cellWidth=3, no separators.992+ tests := []struct {993+ pos int994+ want int995+ }{996+ {0, 0}, {1, 0}, {2, 0}, // cell 0997+ {3, 1}, {4, 1}, {5, 1}, // cell 1998+ {6, 2}, {7, 2}, {8, 2}, // cell 2999+ {9, 3}, {10, 3}, {11, 3}, // cell 31000+ {12, 4}, {13, 4}, {14, 4}, // cell 41001+ {15, -1}, // out of bounds1002+ }1003+ for _, tt := range tests {1004+ got := localToCellX(tt.pos, 5)1005+ if got != tt.want {1006+ t.Errorf("localToCellX(%d, 5) = %d, want %d", tt.pos, got, tt.want)1007+ }1008+ }1009+ })1010+1011+ t.Run("10 cells with one spacer", func(t *testing.T) {1012+ // Cells 0-4, separator, cells 5-9.1013+ // Block width = 5*3+1 = 16.1014+ tests := []struct {1015+ pos int1016+ want int1017+ }{1018+ {0, 0}, // cell 0 start1019+ {2, 0}, // cell 0 end1020+ {3, 1}, // cell 1 start1021+ {12, 4}, // cell 4 start1022+ {14, 4}, // cell 4 end1023+ {15, -1}, // separator1024+ {16, 5}, // cell 5 start1025+ {18, 5}, // cell 5 end1026+ {19, 6}, // cell 6 start1027+ {28, 9}, // cell 9 start1028+ {30, 9}, // cell 9 end1029+ {31, -1}, // out of bounds1030+ }1031+ for _, tt := range tests {1032+ got := localToCellX(tt.pos, 10)1033+ if got != tt.want {1034+ t.Errorf("localToCellX(%d, 10) = %d, want %d", tt.pos, got, tt.want)1035+ }1036+ }1037+ })1038+1039+ t.Run("15 cells with two spacers", func(t *testing.T) {1040+ // Cells 0-4, sep, 5-9, sep, 10-14.1041+ // Total = 15*3 + 2 separators = 47 characters.1042+ tests := []struct {1043+ pos int1044+ want int1045+ }{1046+ {0, 0},1047+ {15, -1}, // first separator1048+ {16, 5}, // cell 51049+ {31, -1}, // second separator1050+ {32, 10}, // cell 101051+ {44, 14}, // cell 141052+ {46, 14}, // still cell 14 (chars 44-46)1053+ }1054+ for _, tt := range tests {1055+ got := localToCellX(tt.pos, 15)1056+ if got != tt.want {1057+ t.Errorf("localToCellX(%d, 15) = %d, want %d", tt.pos, got, tt.want)1058+ }1059+ }1060+ })1061+}1062+1063+func TestLocalToCellY(t *testing.T) {1064+ t.Run("small grid no spacers", func(t *testing.T) {1065+ for i := range 5 {1066+ got := localToCellY(i, 5)1067+ if got != i {1068+ t.Errorf("localToCellY(%d, 5) = %d, want %d", i, got, i)1069+ }1070+ }1071+ if got := localToCellY(5, 5); got != -1 {1072+ t.Errorf("localToCellY(5, 5) = %d, want -1", got)1073+ }1074+ })1075+1076+ t.Run("10 rows with one spacer", func(t *testing.T) {1077+ // Rows 0-4, separator at 5, rows 5-9 at positions 6-10.1078+ tests := []struct {1079+ pos int1080+ want int1081+ }{1082+ {0, 0},1083+ {4, 4},1084+ {5, -1}, // separator1085+ {6, 5},1086+ {10, 9},1087+ {11, -1}, // out of bounds1088+ }1089+ for _, tt := range tests {1090+ got := localToCellY(tt.pos, 10)1091+ if got != tt.want {1092+ t.Errorf("localToCellY(%d, 10) = %d, want %d", tt.pos, got, tt.want)1093+ }1094+ }1095+ })1096+}1097+1098+// --- screenToGrid (P0) ---1099+1100+func TestScreenToGrid(t *testing.T) {1101+ // Build a 5x5 model with known hint sizes and terminal dimensions.1102+ mode := NonogramMode{1103+ BaseMode: game.NewBaseMode("Test", "5x5"),1104+ Width: 5,1105+ Height: 5,1106+ }1107+ hints := Hints{1108+ rows: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1109+ cols: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1110+ }1111+ g, err := New(mode, hints)1112+ if err != nil {1113+ t.Fatal(err)1114+ }1115+ m := g.(Model)1116+ m.termWidth = 801117+ m.termHeight = 401118+1119+ ox, oy := m.gridOrigin()1120+1121+ t.Run("first cell", func(t *testing.T) {1122+ col, row, ok := m.screenToGrid(ox, oy)1123+ if !ok {1124+ t.Fatal("expected ok for first cell")1125+ }1126+ if col != 0 || row != 0 {1127+ t.Errorf("got (%d,%d), want (0,0)", col, row)1128+ }1129+ })1130+1131+ t.Run("last cell", func(t *testing.T) {1132+ col, row, ok := m.screenToGrid(ox+4*cellWidth, oy+4)1133+ if !ok {1134+ t.Fatal("expected ok for last cell")1135+ }1136+ if col != 4 || row != 4 {1137+ t.Errorf("got (%d,%d), want (4,4)", col, row)1138+ }1139+ })1140+1141+ t.Run("outside grid left", func(t *testing.T) {1142+ _, _, ok := m.screenToGrid(ox-1, oy)1143+ if ok {1144+ t.Error("expected not ok for click left of grid")1145+ }1146+ })1147+1148+ t.Run("outside grid above", func(t *testing.T) {1149+ _, _, ok := m.screenToGrid(ox, oy-1)1150+ if ok {1151+ t.Error("expected not ok for click above grid")1152+ }1153+ })1154+}
M nonogram/style.go +2 -2
M view.go +4 -0