- commit
- 179aeca909190755f73c8c4feaa7e8077eecddfd
- parent
- 12dcb68efc27c71c7a0e260a73ef9a785e49509d
- author
- Dami <dami.etoile@gmail.com>
@@ -167,8 +167,7 @@ Several shorthand names are accepted for games: `polyomino`/`regions` for Fillom
167167 | Key | Action |168168 |-----|--------|169169 | `Enter` | Select |170-| `Escape` | Go back |171-| `Ctrl+N` | Return to main menu |170+| `Escape` | Return to the menu or go back |172171 | `Ctrl+R` | Reset puzzle |173172 | `Ctrl+H` | Toggle full help |174173 | `Ctrl+E` | Toggle debug overlay |@@ -4,7 +4,6 @@ import "charm.land/bubbles/v2/key"
44 55 type rootKeyMap struct {66 Quit key.Binding7- MainMenu key.Binding87 Enter key.Binding98 Escape key.Binding109 Debug key.Binding@@ -16,9 +15,6 @@ var rootKeys = rootKeyMap{
1615 Quit: key.NewBinding(1716 key.WithKeys("ctrl+c"),1817 ),19- MainMenu: key.NewBinding(20- key.WithKeys("ctrl+n"),21- ),2218 Enter: key.NewBinding(2319 key.WithKeys("enter"),2420 ),@@ -29,7 +29,7 @@ const (
2929 helpPanelVerticalTrim = 83030 categoryPanelChrome = 83131 categoryBodyMaxWidth = 8632- categoryBodyMaxHeight = 1432+ categoryBodyMaxHeight = 163333 categoryMinListWidth = 243434 categoryMaxListWidth = 303535 categoryGapWidth = 2@@ -322,10 +322,11 @@ func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {
322322 case m.state == gameSelectView && msg.String() == "pgdown":323323 m.nav.categoryDetail.PageDown()324324 return m, nil, true325- case key.Matches(msg, rootKeys.MainMenu):325+ case m.state == gameView && key.Matches(msg, rootKeys.Escape):326326 m = saveCurrentGame(m, store.StatusInProgress)327327 m.state = mainMenuView328328 m.debug.enabled = false329+ return m, nil, true329330 case key.Matches(msg, rootKeys.Enter):330331 if m.state != gameView {331332 next, cmd := m.handleEnter()@@ -64,7 +64,7 @@ func (g *escapeTrackingGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6464 return g, nil6565 }6666 67-func TestGameViewEscapePassesThroughToGame(t *testing.T) {67+func TestGameViewEscapeReturnsToMainMenu(t *testing.T) {6868 tracker := &escapeTrackingGame{}6969 m := model{7070 state: gameView,@@ -74,14 +74,14 @@ func TestGameViewEscapePassesThroughToGame(t *testing.T) {
7474 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})7575 got := next.(model)7676 77- if got.state != gameView {78- t.Fatalf("expected to remain in gameView, got %d", got.state)77+ if got.state != mainMenuView {78+ t.Fatalf("expected to return to mainMenuView, got %d", got.state)7979 }80- if tracker.updateCalls != 1 {81- t.Fatalf("expected game Update to be called once, got %d", tracker.updateCalls)80+ if tracker.updateCalls != 0 {81+ t.Fatalf("expected game Update not to be called, got %d", tracker.updateCalls)8282 }83- if !tracker.sawEscape {84- t.Fatal("expected game to receive escape key")83+ if tracker.sawEscape {84+ t.Fatal("expected escape to be handled by the app before reaching the game")8585 }8686 }8787 @@ -195,18 +195,49 @@ func renderModeList(cat game.Category, width int) string {
195195 return ui.DimItemStyle().Render("No modes available.")196196 }197197 198- lines := make([]string, 0, len(cat.Modes))198+ modeNames := make([]string, 0, len(cat.Modes))199199 for _, item := range cat.Modes {200200 mode, ok := item.(game.Mode)201201 if !ok {202202 continue203203 }204- line := lipgloss.NewStyle().Width(width).Render("• " + mode.Title())205- lines = append(lines, line)204+ modeNames = append(modeNames, "• "+mode.Title())206205 }207- if len(lines) == 0 {206+ if len(modeNames) == 0 {208207 return ui.DimItemStyle().Render("No modes available.")209208 }209+210+ if width < 24 || len(modeNames) == 1 {211+ lines := make([]string, 0, len(modeNames))212+ for _, name := range modeNames {213+ lines = append(lines, lipgloss.NewStyle().Width(width).Render(name))214+ }215+ return strings.Join(lines, "\n")216+ }217+218+ rows := (len(modeNames) + 1) / 2219+ leftWidth := max(width/2-1, 1)220+ rightWidth := max(width-leftWidth-2, 1)221+222+ left := make([]string, 0, rows)223+ right := make([]string, 0, rows)224+ for i, name := range modeNames {225+ line := lipgloss.NewStyle().Width(leftWidth).Render(name)226+ if i < rows {227+ left = append(left, line)228+ continue229+ }230+ line = lipgloss.NewStyle().Width(rightWidth).Render(name)231+ right = append(right, line)232+ }233+ for len(right) < len(left) {234+ right = append(right, strings.Repeat(" ", rightWidth))235+ }236+237+ lines := make([]string, 0, rows)238+ for i := range left {239+ lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, left[i], " ", right[i]))240+ }210241 return strings.Join(lines, "\n")211242 }212243 @@ -18,7 +18,7 @@ Fill the board so that each orthogonally connected region contains exactly as ma
1818 | `Backspace` / `Delete` | Clear cell |1919 | `Ctrl+H` | Toggle help bar |2020 | `Ctrl+R` | Reset puzzle |21-| `Ctrl+N` | Return to main menu |21+| `Escape` | Return to main menu |2222 2323 ## Tips2424 @@ -510,7 +510,7 @@ func completedRegionColor(comp component, colors []color.Color, base color.Color
510510 511511 func statusBarView(showFullHelp bool) string {512512 if showFullHelp {513- return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move ctrl+n: menu ctrl+r: reset ctrl+h: help")513+ return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move esc: menu ctrl+r: reset ctrl+h: help")514514 }515515 return game.StatusBarStyle().Render("1-9: place bkspc: clear")516516 }@@ -17,6 +17,25 @@ type GridBorderColors struct {
1717 SolvedBG color.Color1818 }1919 20+func borderColors(colors GridBorderColors, solved bool, bg color.Color) (fg color.Color, background color.Color) {21+ fg = colors.BorderFG22+ if solved {23+ fg = colors.SolvedBorderFG24+ }25+26+ background = bg27+ if background == nil {28+ background = colors.BackgroundBG29+ if solved {30+ background = colors.SolvedBG31+ }32+ return fg, background33+ }34+35+ // Tinted bridge/crosshair backgrounds need a contrast-aware foreground.36+ return theme.TextOnBG(background), background37+}38+2039 // DefaultBorderColors returns border colors from the active theme.2140 func DefaultBorderColors() GridBorderColors {2241 p := theme.Current()@@ -31,13 +50,12 @@ func DefaultBorderColors() GridBorderColors {
3150 3251 // BorderChar renders a single border character with optional crosshair highlighting.3352 func BorderChar(ch string, colors GridBorderColors, solved, highlight bool) string {34- s := lipgloss.NewStyle().Foreground(colors.BorderFG).Background(colors.BackgroundBG)35- if solved {36- s = lipgloss.NewStyle().Foreground(colors.SolvedBorderFG).Background(colors.SolvedBG)37- } else if highlight {38- s = s.Background(colors.CrosshairBG)53+ bg := color.Color(nil)54+ if highlight && !solved {55+ bg = colors.CrosshairBG3956 }40- return s.Render(ch)57+ fg, bg := borderColors(colors, solved, bg)58+ return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(ch)4159 }4260 4361 // HBorderRow builds a horizontal border row (top or bottom) with per-column@@ -51,12 +69,12 @@ func HBorderRow(w, cursorX, cellWidth int, left, right string, colors GridBorder
5169 }5270 for x := range w {5371 highlight := !solved && x == cursorX54- s := lipgloss.NewStyle().Foreground(colors.BorderFG).Background(colors.BackgroundBG)55- if solved {56- s = lipgloss.NewStyle().Foreground(colors.SolvedBorderFG).Background(colors.SolvedBG)57- } else if highlight {58- s = s.Background(colors.CrosshairBG)72+ bg := color.Color(nil)73+ if highlight {74+ bg = colors.CrosshairBG5975 }76+ fg, bg := borderColors(colors, solved, bg)77+ s := lipgloss.NewStyle().Foreground(fg).Background(bg)6078 parts = append(parts, s.Render(segment))6179 }6280 parts = append(parts, BorderChar(right, colors, solved, false))@@ -6,7 +6,6 @@ import (
66 "strings"77 88 "charm.land/lipgloss/v2"9- "github.com/FelineStateMachine/puzzletea/theme"109 )1110 1211 const DynamicGridCellWidth = 3@@ -47,6 +46,8 @@ type DynamicGridSpec struct {
4746 Colors *GridBorderColors4847 Cell func(x, y int) string4948 ZoneAt func(x, y int) int49+ HasVerticalEdge func(x, y int) bool50+ HasHorizontalEdge func(x, y int) bool5051 ZoneFill func(zone int) color.Color5152 BridgeFill func(bridge DynamicGridBridge) color.Color5253 VerticalBridgeText func(x, y int) string@@ -168,16 +169,7 @@ func dynamicGridColors(spec DynamicGridSpec) GridBorderColors {
168169 }169170 170171 func dynamicGridRenderBorderChar(ch rune, colors GridBorderColors, solved bool, bg color.Color) string {171- fg := colors.BorderFG172- if solved {173- fg = colors.SolvedBorderFG174- }175- if bg == nil {176- bg = colors.BackgroundBG177- if solved {178- bg = colors.SolvedBG179- }180- }172+ fg, bg := borderColors(colors, solved, bg)181173 return lipgloss.NewStyle().182174 Foreground(fg).183175 Background(bg).@@ -239,18 +231,7 @@ func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge Dy
239231 }240232 241233 func dynamicGridRenderBridgeText(text string, width int, colors GridBorderColors, solved bool, bg color.Color) string {242- fg := colors.BorderFG243- if solved {244- fg = colors.SolvedBorderFG245- }246- if bg == nil {247- bg = colors.BackgroundBG248- if solved {249- bg = colors.SolvedBG250- }251- } else {252- fg = theme.TextOnBG(bg)253- }234+ fg, bg := borderColors(colors, solved, bg)254235 255236 return lipgloss.NewStyle().256237 Width(width).@@ -261,6 +242,10 @@ func dynamicGridRenderBridgeText(text string, width int, colors GridBorderColors
261242 }262243 263244 func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {245+ if spec.HasHorizontalEdge != nil {246+ return spec.HasHorizontalEdge(x, y)247+ }248+264249 switch {265250 case y <= 0, y >= spec.Height:266251 return true@@ -270,6 +255,10 @@ func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {
270255 }271256 272257 func dynamicGridHasVerticalEdge(spec DynamicGridSpec, x, y int) bool {258+ if spec.HasVerticalEdge != nil {259+ return spec.HasVerticalEdge(x, y)260+ }261+273262 switch {274263 case x <= 0, x >= spec.Width:275264 return true@@ -79,6 +79,54 @@ func TestRenderDynamicGridUsesUniformRows(t *testing.T) {
7979 }8080 }8181 82+func TestRenderDynamicGridEdgeOverridesSuppressInteriorBorders(t *testing.T) {83+ view := RenderDynamicGrid(DynamicGridSpec{84+ Width: 2,85+ Height: 2,86+ Cell: func(_, _ int) string {87+ return " "88+ },89+ ZoneAt: func(x, y int) int {90+ return y*10 + x91+ },92+ HasVerticalEdge: func(x, _ int) bool {93+ return x <= 0 || x >= 294+ },95+ HasHorizontalEdge: func(_, y int) bool {96+ return y <= 0 || y >= 297+ },98+ })99+100+ lines := strings.Split(ansi.Strip(view), "\n")101+ if got := []rune(lines[1])[4]; got != ' ' {102+ t.Fatalf("interior vertical bridge rune = %q, want %q", got, ' ')103+ }104+ if got := []rune(lines[2])[4]; got != ' ' {105+ t.Fatalf("interior junction rune = %q, want %q", got, ' ')106+ }107+}108+109+func TestRenderDynamicGridDefaultsToZoneEdgesWithoutOverrides(t *testing.T) {110+ view := RenderDynamicGrid(DynamicGridSpec{111+ Width: 2,112+ Height: 2,113+ Cell: func(_, _ int) string {114+ return " "115+ },116+ ZoneAt: func(x, y int) int {117+ return y*10 + x118+ },119+ })120+121+ lines := strings.Split(ansi.Strip(view), "\n")122+ if got := []rune(lines[1])[4]; got != '│' {123+ t.Fatalf("interior vertical bridge rune = %q, want %q", got, '│')124+ }125+ if got := []rune(lines[2])[4]; got != '┼' {126+ t.Fatalf("interior junction rune = %q, want %q", got, '┼')127+ }128+}129+82130 func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {83131 got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), true, nil)84132 want := lipgloss.NewStyle().@@ -90,6 +138,30 @@ func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {
90138 }91139 }92140 141+func TestDynamicGridRenderBorderCharUsesTextOnTintedBackground(t *testing.T) {142+ bg := theme.Current().Surface143+ got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), false, bg)144+ want := lipgloss.NewStyle().145+ Foreground(theme.TextOnBG(bg)).146+ Background(bg).147+ Render("│")148+ if got != want {149+ t.Fatal("expected tinted border char to use contrast-aware foreground")150+ }151+}152+153+func TestBorderCharHighlightUsesTextOnCrosshairBackground(t *testing.T) {154+ colors := DefaultBorderColors()155+ got := BorderChar("│", colors, false, true)156+ want := lipgloss.NewStyle().157+ Foreground(theme.TextOnBG(colors.CrosshairBG)).158+ Background(colors.CrosshairBG).159+ Render("│")160+ if got != want {161+ t.Fatal("expected highlighted border char to use contrast-aware foreground")162+ }163+}164+93165 func TestDynamicGridScreenToCell(t *testing.T) {94166 metrics := DynamicGridMetrics{Width: 3, Height: 3, CellWidth: DynamicGridCellWidth}95167 originX, originY := 10, 20@@ -67,7 +67,7 @@ func TitleBarView(gameName, modeName string, solved bool) string {
6767 title := titleStyle().Render(gameName + " - " + modeName)6868 if solved {6969 badge := solvedBadgeStyle().Render(" SOLVED")70- return title + badge + "\nctrl+n to play again"70+ return title + badge + "\nesc to return to menu"7171 }7272 return lipgloss.NewStyle().PaddingBottom(1).Render(title)7373 }@@ -78,7 +78,7 @@ func (m Model) handleBridgeMode(msg tea.KeyPressMsg) Model {
7878 m = m.cycleBridge(-1, 0)7979 case key.Matches(msg, m.keys.Right):8080 m = m.cycleBridge(1, 0)81- case key.Matches(msg, m.keys.Select), key.Matches(msg, m.keys.Cancel):81+ case key.Matches(msg, m.keys.Select):8282 m.selectedIsland = nil8383 }8484 return m@@ -16,7 +16,7 @@ bridges and all islands are connected.
1616 2. Press an arrow key toward an adjacent island to build a bridge.1717 3. Press the same direction again to upgrade to a double bridge, or a1818 third time to remove the bridge.19-4. Press `Enter`, `Space`, or `Escape` to deselect.19+4. Press `Enter` or `Space` again to deselect.2020 2121 ## Controls2222 @@ -29,14 +29,14 @@ bridges and all islands are connected.
2929 | `Ctrl+R` | Reset puzzle |3030 | `Ctrl+H` | Toggle full help |3131 | `Ctrl+E` | Toggle debug overlay |32-| `Ctrl+N` | Return to main menu |32+| `Escape` | Return to main menu |3333 3434 ### Bridge Mode (after selecting an island)3535 3636 | Key | Action |3737 |-----|--------|3838 | Arrow keys / WASD / hjkl | Build or cycle bridge in direction |39-| `Enter` / `Space` / `Escape` | Deselect island |39+| `Enter` / `Space` | Deselect island |4040 4141 ## Modes4242 @@ -4,6 +4,7 @@ import (
44 "encoding/json"55 "testing"66 7+ tea "charm.land/bubbletea/v2"78 "charm.land/lipgloss/v2"89 )910 @@ -166,6 +167,27 @@ func TestSetBridge(t *testing.T) {
166167 })167168 }168169 170+func TestSelectTogglesBridgeMode(t *testing.T) {171+ p := linePuzzle()172+ m := Model{173+ puzzle: *p,174+ cursorIsland: 0,175+ keys: DefaultKeyMap,176+ }177+178+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})179+ selected := next.(Model)180+ if selected.selectedIsland == nil || *selected.selectedIsland != 0 {181+ t.Fatal("expected enter to select the current island")182+ }183+184+ next, _ = selected.Update(tea.KeyPressMsg{Code: tea.KeyEnter})185+ deselected := next.(Model)186+ if deselected.selectedIsland != nil {187+ t.Fatal("expected enter to deselect the current island")188+ }189+}190+169191 // --- BridgeCount (P0) ---170192 171193 func TestBridgeCount(t *testing.T) {@@ -25,7 +25,7 @@ After selecting an island, you enter Bridge Mode.
2525 | Key | Action |2626 |-----|--------|2727 | `Arrows` / `wasd` / `hjkl` | Build or cycle bridge in direction |28-| `Enter` / `Space` / `Escape` | Deselect island |28+| `Enter` / `Space` | Deselect island |2929 3030 Pressing a direction once builds a single bridge, twice upgrades to a3131 double bridge, and a third time removes it.@@ -36,7 +36,7 @@ double bridge, and a third time removes it.
3636 |-----|--------|3737 | `Ctrl+R` | Reset puzzle |3838 | `Ctrl+H` | Toggle help bar |39-| `Ctrl+N` | Return to main menu |39+| `Escape` | Return to main menu |4040 4141 ## Tips4242 @@ -8,24 +8,19 @@ import (
88 type KeyMap struct {99 game.CursorKeyMap1010 Select key.Binding11- Cancel key.Binding1211 }1312 1413 var DefaultKeyMap = KeyMap{1514 CursorKeyMap: game.DefaultCursorKeyMap,1615 Select: key.NewBinding(1716 key.WithKeys("enter", "space"),18- key.WithHelp("enter/space", "Select"),19- ),20- Cancel: key.NewBinding(21- key.WithKeys("esc"),22- key.WithHelp("esc", "Cancel"),17+ key.WithHelp("enter/space", "Select/Deselect"),2318 ),2419 }2520 2621 func (m Model) GetFullHelp() [][]key.Binding {2722 return [][]key.Binding{2823 {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},29- {m.keys.Select, m.keys.Cancel},24+ {m.keys.Select},3025 }3126 }@@ -290,12 +290,12 @@ func bridgeVView(count int, solved bool) string {
290290 func statusBarView(selected, showFullHelp bool) string {291291 if selected {292292 if showFullHelp {293- return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space/esc: cancel ctrl+n: menu ctrl+r: reset ctrl+h: help")293+ return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space: deselect esc: menu ctrl+r: reset ctrl+h: help")294294 }295- return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space/esc: cancel")295+ return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space: deselect")296296 }297297 if showFullHelp {298- return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island ctrl+n: menu ctrl+r: reset ctrl+h: help")298+ return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island esc: menu ctrl+r: reset ctrl+h: help")299299 }300300 return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island")301301 }@@ -26,7 +26,7 @@ The puzzle is solved when all three rules hold simultaneously.
2626 | `Ctrl+H` | Toggle full help |2727 | `Ctrl+E` | Toggle debug overlay |2828 | `Ctrl+R` | Reset puzzle |29-| `Ctrl+N` | Return to main menu |29+| `Escape` | Return to main menu |3030 3131 ## Modes3232 @@ -23,7 +23,7 @@ The puzzle is solved when all three rules hold simultaneously.
2323 | `Backspace` | Clear cell to unmarked |2424 | `Ctrl+H` | Toggle help bar |2525 | `Ctrl+R` | Reset puzzle |26-| `Ctrl+N` | Return to main menu |26+| `Escape` | Return to main menu |2727 2828 ## Tips2929 @@ -126,7 +126,7 @@ func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conf
126126 127127 func statusBarView(showFullHelp bool) string {128128 if showFullHelp {129- return game.StatusBarStyle().Render("arrows/wasd: move x: shade z: circle bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")129+ return game.StatusBarStyle().Render("arrows/wasd: move x: shade z: circle bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")130130 }131131 return game.StatusBarStyle().Render("x: shade z: circle bkspc: clear")132132 }@@ -19,7 +19,7 @@ goal is to turn every light off.
1919 | `Ctrl+R` | Reset puzzle |2020 | `Ctrl+H` | Toggle full help |2121 | `Ctrl+E` | Toggle debug overlay |22-| `Ctrl+N` | Return to main menu |22+| `Escape` | Return to main menu |2323 2424 ## Modes2525 @@ -20,7 +20,7 @@ Toggle lights in a cross pattern to turn them all off.
2020 | `Enter` / `Space` | Toggle light |2121 | `Ctrl+H` | Toggle help bar |2222 | `Ctrl+R` | Reset puzzle |23-| `Ctrl+N` | Return to main menu |23+| `Escape` | Return to main menu |2424 2525 ### Mouse2626 @@ -140,7 +140,7 @@ func gridView(g [][]bool, c game.Cursor, solved bool) string {
140140 141141 func statusBarView(showFullHelp bool) string {142142 if showFullHelp {143- return game.StatusBarStyle().Render("arrows/wasd: move enter/space/click: toggle ctrl+n: menu ctrl+r: reset ctrl+h: help")143+ return game.StatusBarStyle().Render("arrows/wasd: move enter/space/click: toggle esc: menu ctrl+r: reset ctrl+h: help")144144 }145145 return game.StatusBarStyle().Render("enter/space/click: toggle")146146 }@@ -23,7 +23,7 @@ Fill cells to match row and column hints, revealing a hidden pattern.
2323 | `Backspace` | Clear cell |2424 | `Ctrl+H` | Toggle help bar |2525 | `Ctrl+R` | Reset puzzle |26-| `Ctrl+N` | Return to main menu |26+| `Escape` | Return to main menu |2727 2828 ### Mouse2929 @@ -263,7 +263,7 @@ func tileView(val rune, isCursor, inCursorRow, inCursorCol, solved bool) string
263263 264264 func statusBarView(showFullHelp bool) string {265265 if showFullHelp {266- return game.StatusBarStyle().Render("arrows/wasd: move z: fill (hold+move) x: mark (hold+move) bkspc: clear LMB: fill RMB: mark ctrl+n: menu ctrl+r: reset ctrl+h: help")266+ return game.StatusBarStyle().Render("arrows/wasd: move z: fill (hold+move) x: mark (hold+move) bkspc: clear LMB: fill RMB: mark esc: menu ctrl+r: reset ctrl+h: help")267267 }268268 return game.StatusBarStyle().Render("z: fill x: mark bkspc: clear mouse: click/drag")269269 }@@ -129,7 +129,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
129129 if m.solved || !m.dragging {130130 break131131 }132- col, row, ok := m.screenToGrid(msg.X, msg.Y)132+ col, row, ok := m.screenToGridDrag(msg.X, msg.Y)133133 if !ok {134134 break135135 }@@ -24,7 +24,7 @@ Clue cells are fixed and always island cells.
2424 | `Ctrl+R` | Reset puzzle |2525 | `Ctrl+H` | Toggle full help |2626 | `Ctrl+E` | Toggle debug overlay |27-| `Ctrl+N` | Return to main menu |27+| `Escape` | Return to main menu |2828 2929 ## Mouse3030 @@ -22,7 +22,7 @@ The puzzle is solved when all cells are decided and all rules hold.
2222 | `Backspace` | Clear to unknown |2323 | `Ctrl+H` | Toggle help bar |2424 | `Ctrl+R` | Reset puzzle |25-| `Ctrl+N` | Return to main menu |25+| `Escape` | Return to main menu |2626 2727 ## Mouse2828 @@ -1,29 +1,33 @@
11 package nurikabe22 33 import (4- "strings"5-6- "charm.land/lipgloss/v2"74 "github.com/FelineStateMachine/puzzletea/game"85 )96 107 // screenToGrid converts terminal screen coordinates to grid cell coordinates.118 // Returns (col, row, ok) where ok is false if click landed outside a cell.129 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- }10+ return m.screenToGridAt(screenX, screenY, false)11+}1912 20- col = lx / cellWidth21- row = ly13+func (m *Model) screenToGridDrag(screenX, screenY int) (col, row int, ok bool) {14+ return m.screenToGridAt(screenX, screenY, true)15+}2216 23- if col < 0 || col >= m.width || row < 0 || row >= m.height {24- return 0, 0, false25- }26- return col, row, true17+func (m *Model) screenToGridAt(screenX, screenY int, includeSeparators bool) (col, row int, ok bool) {18+ ox, oy := m.cachedGridOrigin()19+ return game.DynamicGridScreenToCell(20+ game.DynamicGridMetrics{21+ Width: m.width,22+ Height: m.height,23+ CellWidth: cellWidth,24+ },25+ ox,26+ oy,27+ screenX,28+ screenY,29+ includeSeparators,30+ )2731 }2832 2933 func (m *Model) cachedGridOrigin() (x, y int) {@@ -37,27 +41,7 @@ func (m *Model) cachedGridOrigin() (x, y int) {
3741 }3842 3943 func (m *Model) gridOrigin() (x, y int) {40- gameView := m.View()41- viewWidth := lipgloss.Width(gameView)42- viewHeight := lipgloss.Height(gameView)43-44- if viewWidth > m.termWidth {45- viewWidth = m.termWidth46- }47-48- centerX := max((m.termWidth-viewWidth)/2, 0)49- centerY := max((m.termHeight-viewHeight)/2, 0)50-5144 title := game.TitleBarView("Nurikabe", m.modeTitle, m.solved)52- titleHeight := strings.Count(title, "\n") + 153-54- gridOuterWidth := lipgloss.Width(gridView(*m))55- gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)56-57- const borderLeft = 158- const borderTop = 159-60- x = centerX + gridPadLeft + borderLeft61- y = centerY + titleHeight + borderTop62- return x, y45+ grid := gridView(*m)46+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)6347 }@@ -3,13 +3,18 @@ package nurikabe
33 import (44 "context"55 "errors"6+ "image"7+ "image/color"68 "math/rand/v2"79 "runtime"10+ "strings"811 "testing"912 "time"1013 1114 tea "charm.land/bubbletea/v2"1215 "github.com/FelineStateMachine/puzzletea/game"16+ "github.com/FelineStateMachine/puzzletea/theme"17+ "github.com/charmbracelet/x/ansi"1318 )1419 1520 func makeClues(rows ...[]int) clueGrid {@@ -252,6 +257,24 @@ func pointToCursor(x, y int) game.Cursor {
252257 return game.Cursor{X: x, Y: y}253258 }254259 260+func cellPoint(originX, originY, col, row int) (x, y int) {261+ return originX + col*(cellWidth+1), originY + row*2262+}263+264+func separatorPoint(originX, originY, col, row int) (x, y int) {265+ x, y = cellPoint(originX, originY, col, row)266+ return x - 1, y267+}268+269+func sameColor(left, right color.Color) bool {270+ if left == nil || right == nil {271+ return left == nil && right == nil272+ }273+ lr, lg, lb, la := left.RGBA()274+ rr, rg, rb, ra := right.RGBA()275+ return lr == rr && lg == rg && lb == rb && la == ra276+}277+255278 // --- Model input behavior (P1) ---256279 257280 func TestModelKeyboardUpdate(t *testing.T) {@@ -303,12 +326,14 @@ func TestModelMouseDragPaint(t *testing.T) {
303326 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})304327 m = next.(Model)305328 ox, oy := m.gridOrigin()329+ clickX, clickY := cellPoint(ox, oy, 1, 1)330+ dragX, dragY := separatorPoint(ox, oy, 2, 1)306331 307- click := tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseLeft}332+ click := tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft}308333 next, _ = m.Update(click)309334 m = next.(Model)310335 311- drag := tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseLeft}336+ drag := tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseLeft}312337 next, _ = m.Update(drag)313338 m = next.(Model)314339 @@ -337,8 +362,7 @@ func TestModelMouseClickMirrorsKeyboard(t *testing.T) {
337362 m = next.(Model)338363 ox, oy := m.gridOrigin()339364 340- cellX := ox + cellWidth341- cellY := oy + 1365+ cellX, cellY := cellPoint(ox, oy, 1, 1)342366 343367 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})344368 m = next.(Model)@@ -388,8 +412,7 @@ func TestModelMouseRightClickTogglesIsland(t *testing.T) {
388412 m = next.(Model)389413 ox, oy := m.gridOrigin()390414 391- cellX := ox + cellWidth392- cellY := oy + 1415+ cellX, cellY := cellPoint(ox, oy, 1, 1)393416 394417 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})395418 m = next.(Model)@@ -420,11 +443,13 @@ func TestModelMouseRightDragPaintIsland(t *testing.T) {
420443 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})421444 m = next.(Model)422445 ox, oy := m.gridOrigin()446+ clickX, clickY := cellPoint(ox, oy, 1, 1)447+ dragX, dragY := separatorPoint(ox, oy, 2, 1)423448 424- next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseRight})449+ next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseRight})425450 m = next.(Model)426451 427- next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseRight})452+ next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseRight})428453 m = next.(Model)429454 430455 next, _ = m.Update(tea.MouseReleaseMsg{})@@ -455,11 +480,13 @@ func TestModelMouseRightDragClearsIsland(t *testing.T) {
455480 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})456481 m = next.(Model)457482 ox, oy := m.gridOrigin()483+ clickX, clickY := cellPoint(ox, oy, 1, 1)484+ dragX, dragY := separatorPoint(ox, oy, 2, 1)458485 459- next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseRight})486+ next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseRight})460487 m = next.(Model)461488 462- next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseRight})489+ next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseRight})463490 m = next.(Model)464491 465492 next, _ = m.Update(tea.MouseReleaseMsg{})@@ -488,11 +515,13 @@ func TestModelMouseLeftDragPaintSeaFromSea(t *testing.T) {
488515 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})489516 m = next.(Model)490517 ox, oy := m.gridOrigin()518+ clickX, clickY := cellPoint(ox, oy, 1, 1)519+ dragX, dragY := separatorPoint(ox, oy, 2, 1)491520 492- next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseLeft})521+ next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})493522 m = next.(Model)494523 495- next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseLeft})524+ next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseLeft})496525 m = next.(Model)497526 498527 next, _ = m.Update(tea.MouseReleaseMsg{})@@ -530,6 +559,233 @@ func TestHelpToggleInvalidatesOriginCache(t *testing.T) {
530559 }531560 }532561 562+func TestWindowSizeInvalidatesOriginCache(t *testing.T) {563+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)564+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(565+ []int{2, 0, 0},566+ []int{0, 0, 0},567+ []int{0, 0, 1},568+ )}569+ g, err := New(mode, p)570+ if err != nil {571+ t.Fatalf("New error: %v", err)572+ }573+ m := g.(Model)574+ m.originValid = true575+ m.originX = 5576+ m.originY = 7577+578+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})579+ got := next.(Model)580+581+ if got.originValid {582+ t.Fatal("expected origin cache to be invalidated")583+ }584+}585+586+func TestResetInvalidatesOriginCache(t *testing.T) {587+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)588+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(589+ []int{2, 0, 0},590+ []int{0, 0, 0},591+ []int{0, 0, 1},592+ )}593+ g, err := New(mode, p)594+ if err != nil {595+ t.Fatalf("New error: %v", err)596+ }597+ m := g.(Model)598+ m.originValid = true599+600+ got := m.Reset().(Model)601+ if got.originValid {602+ t.Fatal("expected reset to invalidate origin cache")603+ }604+}605+606+func TestSolvedStateTransitionInvalidatesOriginCache(t *testing.T) {607+ mode := NewMode("Mini", "test", 5, 5, 0.3, 5)608+ p := uniquePuzzleFixture()609+ g, err := New(mode, p)610+ if err != nil {611+ t.Fatalf("New error: %v", err)612+ }613+ m := g.(Model)614+ m.originValid = true615+ m.marks = makeMarksFromClues(p.Clues)616+617+ m.afterBoardChange()618+619+ if !m.solved {620+ t.Fatal("expected puzzle to be solved")621+ }622+ if m.originValid {623+ t.Fatal("expected solved transition to invalidate origin cache")624+ }625+}626+627+func TestScreenToGridRejectsSeparatorClicks(t *testing.T) {628+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)629+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(630+ []int{2, 0, 0},631+ []int{0, 0, 0},632+ []int{0, 0, 1},633+ )}634+ g, err := New(mode, p)635+ if err != nil {636+ t.Fatalf("New error: %v", err)637+ }638+ m := g.(Model)639+640+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})641+ m = next.(Model)642+ ox, oy := m.gridOrigin()643+ separatorX, separatorY := separatorPoint(ox, oy, 2, 1)644+645+ if _, _, ok := m.screenToGrid(separatorX, separatorY); ok {646+ t.Fatal("expected separator click to miss")647+ }648+}649+650+func TestScreenToGridDragSnapsAcrossSeparator(t *testing.T) {651+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)652+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(653+ []int{2, 0, 0},654+ []int{0, 0, 0},655+ []int{0, 0, 1},656+ )}657+ g, err := New(mode, p)658+ if err != nil {659+ t.Fatalf("New error: %v", err)660+ }661+ m := g.(Model)662+663+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})664+ m = next.(Model)665+ ox, oy := m.gridOrigin()666+ separatorX, separatorY := separatorPoint(ox, oy, 2, 1)667+668+ col, row, ok := m.screenToGridDrag(separatorX, separatorY)669+ if !ok {670+ t.Fatal("expected separator drag to snap to adjacent cell")671+ }672+ if col != 2 || row != 1 {673+ t.Fatalf("drag snapped to (%d,%d), want (2,1)", col, row)674+ }675+}676+677+func TestGridViewUsesDynamicGridWithoutInteriorSeparators(t *testing.T) {678+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)679+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(680+ []int{2, 0, 0},681+ []int{0, 0, 0},682+ []int{0, 0, 1},683+ )}684+ g, err := New(mode, p)685+ if err != nil {686+ t.Fatalf("New error: %v", err)687+ }688+ m := g.(Model)689+ m.cursor = pointToCursor(1, 1)690+ m.setCellAtCursor(seaCell)691+ m.cursor = pointToCursor(2, 1)692+ m.setCellAtCursor(islandCell)693+694+ lines := strings.Split(ansi.Strip(gridView(m)), "\n")695+ if got, want := len(lines), m.height*2+1; got != want {696+ t.Fatalf("line count = %d, want %d", got, want)697+ }698+ if !strings.HasPrefix(lines[0], "┌") || !strings.HasSuffix(lines[0], "┐") {699+ t.Fatal("expected top border")700+ }701+ last := lines[len(lines)-1]702+ if !strings.HasPrefix(last, "└") || !strings.HasSuffix(last, "┘") {703+ t.Fatal("expected bottom border")704+ }705+ for _, idx := range []int{1, 2, 3, 4, 5} {706+ line := lines[idx]707+ if strings.Count(line, "│") != 2 {708+ t.Fatalf("line %d = %q, want only outer vertical borders", idx, line)709+ }710+ }711+ for _, idx := range []int{2, 4} {712+ if strings.Contains(lines[idx], "─") {713+ t.Fatalf("line %d = %q, want no interior horizontal separators", idx, lines[idx])714+ }715+ }716+}717+718+func TestBridgeFillBlendsMixedTerrainBackgrounds(t *testing.T) {719+ mode := NewMode("Mini", "test", 3, 1, 0.3, 5)720+ p := Puzzle{Width: 3, Height: 1, Clues: makeClues(721+ []int{1, 0, 0},722+ )}723+ g, err := New(mode, p)724+ if err != nil {725+ t.Fatalf("New error: %v", err)726+ }727+ m := g.(Model)728+ m.cursor = pointToCursor(1, 0)729+ m.setCellAtCursor(seaCell)730+ m.cursor = pointToCursor(2, 0)731+732+ got := bridgeFill(m, game.DynamicGridBridge{733+ Kind: game.DynamicGridBridgeVertical,734+ X: 1,735+ Y: 0,736+ Count: 2,737+ Uniform: false,738+ Cells: [4]image.Point{739+ {X: 0, Y: 0},740+ {X: 1, Y: 0},741+ },742+ })743+744+ palette := theme.Current()745+ want := color.NRGBA{}746+ land := theme.Blend(palette.BG, palette.Success, 0.45)747+ sea := theme.Blend(palette.BG, palette.Secondary, 0.24)748+ lr, lg, lb, la := land.RGBA()749+ sr, sg, sb, sa := sea.RGBA()750+ want.R = uint8(((lr >> 8) + (sr >> 8)) / 2)751+ want.G = uint8(((lg >> 8) + (sg >> 8)) / 2)752+ want.B = uint8(((lb >> 8) + (sb >> 8)) / 2)753+ want.A = uint8(((la >> 8) + (sa >> 8)) / 2)754+755+ if !sameColor(got, want) {756+ t.Fatal("expected mixed land/sea bridge to use blended background")757+ }758+}759+760+func TestBridgeFillDoesNotExpandCursorBackground(t *testing.T) {761+ mode := NewMode("Mini", "test", 3, 1, 0.3, 5)762+ p := Puzzle{Width: 3, Height: 1, Clues: makeClues(763+ []int{1, 0, 0},764+ )}765+ g, err := New(mode, p)766+ if err != nil {767+ t.Fatalf("New error: %v", err)768+ }769+ m := g.(Model)770+ m.cursor = pointToCursor(1, 0)771+ m.setCellAtCursor(seaCell)772+ got := bridgeFill(m, game.DynamicGridBridge{773+ Kind: game.DynamicGridBridgeVertical,774+ X: 1,775+ Y: 0,776+ Count: 2,777+ Uniform: false,778+ Cells: [4]image.Point{779+ {X: 0, Y: 0},780+ {X: 1, Y: 0},781+ },782+ })783+784+ if sameColor(got, game.CursorBG()) {785+ t.Fatal("expected cursor color to stay inside the cell body")786+ }787+}788+533789 // --- Seeded generation determinism (P1) ---534790 535791 func TestGenerateSeededDeterministicConcurrent(t *testing.T) {@@ -2,92 +2,199 @@ package nurikabe
22 33 import (44 "fmt"5+ "image/color"56 67 "charm.land/lipgloss/v2"78 "github.com/FelineStateMachine/puzzletea/game"89 "github.com/FelineStateMachine/puzzletea/theme"910 )1011 11-const cellWidth = 312+const cellWidth = game.DynamicGridCellWidth1213 13-func gridBorderStyle() lipgloss.Style {14- p := theme.Current()15- return lipgloss.NewStyle().16- Border(lipgloss.RoundedBorder()).17- BorderForeground(p.Border).18- BorderBackground(p.BG)14+type cellVisual struct {15+ text string16+ fg color.Color17+ bg color.Color18+ bridgeBG color.Color19+ bold bool20+ kind cellVisualKind1921 }2022 21-func gridBorderSolvedStyle() lipgloss.Style {22- p := theme.Current()23- return lipgloss.NewStyle().24- Border(lipgloss.RoundedBorder()).25- BorderForeground(p.SuccessBorder).26- BorderBackground(p.SuccessBG)27-}23+type cellVisualKind int24+25+const (26+ cellVisualLand cellVisualKind = iota27+ cellVisualSea28+ cellVisualCursor29+ cellVisualSolved30+ cellVisualConflict31+)2832 29-func cellStyle(c cellState, clue int, isCursor, solved, conflict, inSeaSquare bool) (lipgloss.Style, string) {33+func resolveCellVisual(m Model, x, y int) cellVisual {3034 p := theme.Current()31- display := " "3235 landBg := theme.Blend(p.BG, p.Success, 0.45)33- style := lipgloss.NewStyle().Background(landBg).Foreground(theme.TextOnBG(landBg))36+ seaBg := theme.Blend(p.BG, p.Secondary, 0.24)37+ c := m.marks[y][x]38+ clue := m.clues[y][x]39+ isCursor := x == m.cursor.X && y == m.cursor.Y40+ conflict := m.conflicts[y][x]41+ inSeaSquare := isSeaSquareCell(m.marks, x, y)42+ visual := cellVisual{43+ text: " ",44+ fg: theme.TextOnBG(landBg),45+ bg: landBg,46+ bridgeBG: landBg,47+ kind: cellVisualLand,48+ }3449 3550 switch {3651 case clue > 0:37- display = fmt.Sprintf("%2d ", clue)52+ visual.text = fmt.Sprintf("%2d ", clue)3853 if clue < 10 {39- display = fmt.Sprintf(" %d ", clue)54+ visual.text = fmt.Sprintf(" %d ", clue)4055 }41-42- style = lipgloss.NewStyle().43- Foreground(p.Info).44- Background(landBg).45- Bold(true)56+ visual.fg = p.Info57+ visual.bold = true4658 case c == seaCell:47- display = " ~ "59+ visual.text = " ~ "4860 if inSeaSquare {49- display = " @ "61+ visual.text = " @ "5062 }51- seaBg := theme.Blend(p.BG, p.Secondary, 0.24)52- style = style.Background(seaBg).Foreground(theme.TextOnBG(seaBg))63+ visual.bg = seaBg64+ visual.bridgeBG = seaBg65+ visual.fg = theme.TextOnBG(seaBg)66+ visual.kind = cellVisualSea5367 case c == islandCell:54- display = " \u00b7 "55- style = style.Background(landBg).Foreground(theme.TextOnBG(landBg))68+ visual.text = " \u00b7 "69+ }70+71+ switch {72+ case conflict:73+ visual.bg = game.ConflictBG()74+ visual.bridgeBG = game.ConflictBG()75+ visual.fg = game.ConflictFG()76+ visual.kind = cellVisualConflict77+ case m.solved:78+ visual.bg = p.SuccessBG79+ visual.bridgeBG = p.SuccessBG80+ visual.fg = theme.TextOnBG(p.SuccessBG)81+ visual.kind = cellVisualSolved82+ case isCursor:83+ visual.bg = game.CursorBG()84+ visual.fg = game.CursorFG()85+ visual.kind = cellVisualCursor86+ }87+88+ return visual89+}90+91+func renderCellVisual(visual cellVisual) string {92+ style := lipgloss.NewStyle().93+ Width(cellWidth).94+ AlignHorizontal(lipgloss.Center).95+ Foreground(visual.fg).96+ Background(visual.bg)97+ if visual.bold {98+ style = style.Bold(true)99+ }100+ return style.Render(visual.text)101+}102+103+func colorsMatch(left, right color.Color) bool {104+ if left == nil || right == nil {105+ return left == nil && right == nil56106 }107+ lr, lg, lb, la := left.RGBA()108+ rr, rg, rb, ra := right.RGBA()109+ return lr == rr && lg == rg && lb == rb && la == ra110+}57111 58- if solved {59- style = style.Background(p.SuccessBG).Foreground(theme.TextOnBG(p.SuccessBG))112+func dominantBridgeBackground(visuals []cellVisual) color.Color {113+ for _, kind := range []cellVisualKind{114+ cellVisualConflict,115+ cellVisualSolved,116+ cellVisualCursor,117+ } {118+ for _, visual := range visuals {119+ if visual.kind == kind {120+ return visual.bridgeBG121+ }122+ }60123 }61- if conflict {62- style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG())124+ return nil125+}126+127+func blendBridgeBackgrounds(visuals []cellVisual) color.Color {128+ if len(visuals) == 0 {129+ return nil63130 }64- if isCursor && solved {65- style = game.CursorSolvedStyle()66- } else if isCursor {67- style = game.CursorStyle()131+132+ var rSum, gSum, bSum, aSum uint32133+ for _, visual := range visuals {134+ r, g, b, a := visual.bridgeBG.RGBA()135+ rSum += uint32(r >> 8)136+ gSum += uint32(g >> 8)137+ bSum += uint32(b >> 8)138+ aSum += uint32(a >> 8)68139 }69140 70- return style.Width(cellWidth).AlignHorizontal(lipgloss.Center), display141+ count := uint32(len(visuals))142+ return color.NRGBA{143+ R: uint8(rSum / count),144+ G: uint8(gSum / count),145+ B: uint8(bSum / count),146+ A: uint8(aSum / count),147+ }71148 }72149 73-func gridView(m Model) string {74- rows := make([]string, m.height)75- for y := range m.height {76- cells := make([]string, m.width)77- for x := range m.width {78- isCursor := x == m.cursor.X && y == m.cursor.Y79- inSeaSquare := isSeaSquareCell(m.marks, x, y)80- style, display := cellStyle(m.marks[y][x], m.clues[y][x], isCursor, m.solved, m.conflicts[y][x], inSeaSquare)81- cells[x] = style.Render(display)150+func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {151+ if bridge.Count == 0 {152+ return nil153+ }154+155+ visuals := make([]cellVisual, 0, bridge.Count)156+ for i := 0; i < bridge.Count; i++ {157+ cell := bridge.Cells[i]158+ visuals = append(visuals, resolveCellVisual(m, cell.X, cell.Y))159+ }160+161+ bg := visuals[0].bridgeBG162+ allMatch := true163+ for _, visual := range visuals[1:] {164+ if !colorsMatch(bg, visual.bridgeBG) {165+ allMatch = false166+ break82167 }83- rows[y] = lipgloss.JoinHorizontal(lipgloss.Top, cells...)168+ }169+ if allMatch {170+ return bg84171 }85172 86- content := lipgloss.JoinVertical(lipgloss.Left, rows...)87- if m.solved {88- return gridBorderSolvedStyle().Render(content)173+ if dominant := dominantBridgeBackground(visuals); dominant != nil {174+ return dominant89175 }90- return gridBorderStyle().Render(content)176+177+ return blendBridgeBackgrounds(visuals)178+}179+180+func gridView(m Model) string {181+ return game.RenderDynamicGrid(game.DynamicGridSpec{182+ Width: m.width,183+ Height: m.height,184+ Solved: m.solved,185+ Cell: func(x, y int) string {186+ return renderCellVisual(resolveCellVisual(m, x, y))187+ },188+ HasVerticalEdge: func(x, _ int) bool {189+ return x <= 0 || x >= m.width190+ },191+ HasHorizontalEdge: func(_, y int) bool {192+ return y <= 0 || y >= m.height193+ },194+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {195+ return bridgeFill(m, bridge)196+ },197+ })91198 }92199 93200 // isSeaSquareCell reports whether (x,y) is part of any 2x2 sea block.@@ -123,7 +230,7 @@ func isSeaSquareCell(marks grid, x, y int) bool {
123230 124231 func statusBarView(showFullHelp bool) string {125232 if showFullHelp {126- return game.StatusBarStyle().Render("arrows/wasd: move x/LMB: sea z/RMB: island bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")233+ return game.StatusBarStyle().Render("arrows/wasd: move x/LMB: sea z/RMB: island bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")127234 }128235 return game.StatusBarStyle().Render("x/LMB: sea z/RMB: island bkspc: clear")129236 }@@ -16,6 +16,7 @@ type Mode struct {
1616 Size int1717 MaxCage int1818 GivenRatio float6419+ profile generationProfile1920 }2021 2122 var (@@ -25,16 +26,21 @@ var (
2526 )2627 2728 func NewMode(title, description string, size, maxCage int, givenRatio float64) Mode {29+ return NewModeWithProfile(title, description, size, maxCage, givenRatio, defaultGenerationProfile(maxCage))30+}31+32+func NewModeWithProfile(title, description string, size, maxCage int, givenRatio float64, profile generationProfile) Mode {2833 return Mode{2934 BaseMode: game.NewBaseMode(title, description),3035 Size: size,3136 MaxCage: maxCage,3237 GivenRatio: givenRatio,38+ profile: profile,3339 }3440 }3541 3642 func (m Mode) Spawn() (game.Gamer, error) {37- puzzle, err := GeneratePuzzle(m.Size, m.Size, m.MaxCage, m.GivenRatio)43+ puzzle, err := m.generatePuzzle()3844 if err != nil {3945 return nil, err4046 }@@ -42,7 +48,7 @@ func (m Mode) Spawn() (game.Gamer, error) {
4248 }4349 4450 func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {45- puzzle, err := GeneratePuzzleSeeded(m.Size, m.Size, m.MaxCage, m.GivenRatio, rng)51+ puzzle, err := m.generatePuzzleSeeded(rng)4652 if err != nil {4753 return nil, err4854 }@@ -50,11 +56,71 @@ func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
5056 }5157 5258 var Modes = []list.Item{53- NewMode("Mini 5x5", "Small board with compact cages.", 5, 3, 0.76),54- NewMode("Easy 6x6", "Gentle introduction to ripple spacing.", 6, 3, 0.72),55- NewMode("Medium 7x7", "Balanced cages and row pressure.", 7, 4, 0.68),56- NewMode("Hard 8x8", "Denser ripple interactions.", 8, 4, 0.64),57- NewMode("Expert 9x9", "Wide board with longer deduction chains.", 9, 5, 0.60),59+ NewModeWithProfile(60+ "Mini 5x5",61+ "Compact cages with extra anchors for quick local reads.",62+ 5,63+ 3,64+ 0.72,65+ generationProfile{66+ cageWeights: []int{0, 5, 6, 3},67+ frontierSamples: 3,68+ shapeBias: shapeBiasCompact,69+ minGivensByCage: []int{0, 1, 1, 2},70+ },71+ ),72+ NewModeWithProfile(73+ "Easy 6x6",74+ "Gentle spacing logic with compact cages and steady clues.",75+ 6,76+ 3,77+ 0.67,78+ generationProfile{79+ cageWeights: []int{0, 3, 5, 4},80+ frontierSamples: 3,81+ shapeBias: shapeBiasCompact,82+ minGivensByCage: []int{0, 1, 1, 2},83+ },84+ ),85+ NewModeWithProfile(86+ "Medium 7x7",87+ "Mixed cage shapes with a balanced clue spread.",88+ 7,89+ 4,90+ 0.62,91+ generationProfile{92+ cageWeights: []int{0, 2, 4, 5, 3},93+ frontierSamples: 2,94+ shapeBias: shapeBiasNeutral,95+ minGivensByCage: []int{0, 1, 1, 1, 1},96+ },97+ ),98+ NewModeWithProfile(99+ "Hard 8x8",100+ "Longer cages and lighter anchors create broader ripple scans.",101+ 8,102+ 4,103+ 0.58,104+ generationProfile{105+ cageWeights: []int{0, 1, 2, 4, 5},106+ frontierSamples: 3,107+ shapeBias: shapeBiasWinding,108+ minGivensByCage: []int{0, 1, 1, 1, 1},109+ },110+ ),111+ NewModeWithProfile(112+ "Expert 9x9",113+ "Sparser anchors and winding large cages push global deductions.",114+ 9,115+ 5,116+ 0.54,117+ generationProfile{118+ cageWeights: []int{0, 1, 1, 3, 4, 5},119+ frontierSamples: 4,120+ shapeBias: shapeBiasWinding,121+ minGivensByCage: []int{0, 1, 1, 1, 1, 1},122+ },123+ ),58124 }59125 60126 var DailyModes = []list.Item{@@ -72,3 +138,12 @@ var Definition = game.Definition{
72138 Help: HelpContent,73139 Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },74140 }141+142+func (m Mode) generatePuzzle() (Puzzle, error) {143+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))144+ return m.generatePuzzleSeeded(rng)145+}146+147+func (m Mode) generatePuzzleSeeded(rng *rand.Rand) (Puzzle, error) {148+ return generatePuzzleSeededWithProfile(m.Size, m.Size, m.MaxCage, m.GivenRatio, m.profile.withDefaults(m.MaxCage), rng)149+}@@ -6,11 +6,11 @@ Place digits into irregular cages so each cage contains `1..n` exactly once, and
66 77 | Mode | Size | Notes |88 |------|------|-------|9-| Mini 5x5 | 5x5 | Compact intro board |10-| Easy 6x6 | 6x6 | Gentle spacing logic |11-| Medium 7x7 | 7x7 | Balanced cages and ripples |12-| Hard 8x8 | 8x8 | Denser interactions |13-| Expert 9x9 | 9x9 | Wider reasoning chains |9+| Mini 5x5 | 5x5 | Compact cages with extra anchors |10+| Easy 6x6 | 6x6 | Steady clues and approachable spacing |11+| Medium 7x7 | 7x7 | Mixed cage shapes and balanced clue spread |12+| Hard 8x8 | 8x8 | Longer cages with lighter anchoring |13+| Expert 9x9 | 9x9 | Winding large cages and sparse anchors |1414 1515 ## CLI1616 @@ -13,15 +13,35 @@ type Puzzle struct {
1313 Solution grid1414 }1515 16+type shapeBias int17+18+const (19+ shapeBiasNeutral shapeBias = iota20+ shapeBiasCompact21+ shapeBiasWinding22+)23+24+type generationProfile struct {25+ cageWeights []int26+ frontierSamples int27+ shapeBias shapeBias28+ minGivensByCage []int29+}30+1631 func GeneratePuzzle(width, height, maxCage int, givenRatio float64) (Puzzle, error) {1732 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))1833 return GeneratePuzzleSeeded(width, height, maxCage, givenRatio, rng)1934 }2035 2136 func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *rand.Rand) (Puzzle, error) {37+ return generatePuzzleSeededWithProfile(width, height, maxCage, givenRatio, defaultGenerationProfile(maxCage), rng)38+}39+40+func generatePuzzleSeededWithProfile(width, height, maxCage int, givenRatio float64, profile generationProfile, rng *rand.Rand) (Puzzle, error) {2241 const maxAttempts = 2442+ profile = profile.withDefaults(maxCage)2343 for attempt := 0; attempt < maxAttempts; attempt++ {24- cages := generateCages(width, height, maxCage, rng)44+ cages := generateCages(width, height, maxCage, profile, rng)2545 geo, err := buildGeometry(width, height, cages)2646 if err != nil {2747 continue@@ -32,7 +52,7 @@ func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *r
3252 continue3353 }3454 35- givens := removeClues(geo, solution, givenRatio, rng)55+ givens := removeClues(geo, solution, givenRatio, profile, rng)3656 if countSolutions(geo, givens, 2) != 1 {3757 continue3858 }@@ -49,7 +69,50 @@ func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *r
4969 return Puzzle{}, fmt.Errorf("generate ripple effect puzzle %dx%d: exceeded retry budget", width, height)5070 }5171 52-func generateCages(width, height, maxCage int, rng *rand.Rand) []Cage {72+func defaultGenerationProfile(maxCage int) generationProfile {73+ return generationProfile{74+ cageWeights: defaultCageWeights(maxCage),75+ frontierSamples: 1,76+ shapeBias: shapeBiasNeutral,77+ minGivensByCage: make([]int, maxCage+1),78+ }79+}80+81+func defaultCageWeights(maxCage int) []int {82+ base := []int{0, 2, 4, 5, 4, 3, 2, 1, 1, 1}83+ weights := make([]int, maxCage+1)84+ for size := range len(weights) {85+ if size == 0 {86+ continue87+ }88+ weights[size] = base[min(size, len(base)-1)]89+ }90+ return weights91+}92+93+func (p generationProfile) withDefaults(maxCage int) generationProfile {94+ if len(p.cageWeights) == 0 {95+ p.cageWeights = defaultCageWeights(maxCage)96+ }97+ if p.frontierSamples <= 0 {98+ p.frontierSamples = 199+ }100+ if len(p.minGivensByCage) == 0 {101+ p.minGivensByCage = make([]int, maxCage+1)102+ }103+ if len(p.minGivensByCage) <= maxCage {104+ p.minGivensByCage = growIntSlice(p.minGivensByCage, maxCage+1)105+ }106+ return p107+}108+109+func growIntSlice(values []int, size int) []int {110+ grown := make([]int, size)111+ copy(grown, values)112+ return grown113+}114+115+func generateCages(width, height, maxCage int, profile generationProfile, rng *rand.Rand) []Cage {53116 assigned := make([][]bool, height)54117 for y := range height {55118 assigned[y] = make([]bool, width)@@ -62,8 +125,8 @@ func generateCages(width, height, maxCage int, rng *rand.Rand) []Cage {
62125 return cages63126 }64127 65- target := chooseCageSize(maxCage, remainingUnassigned(assigned), rng)66- shape := growCageShape(start, target, assigned, rng)128+ target := chooseCageSize(maxCage, remainingUnassigned(assigned), profile.cageWeights, rng)129+ shape := growCageShape(start, target, assigned, profile, rng)67130 for _, cell := range shape {68131 assigned[cell.y][cell.x] = true69132 }@@ -103,11 +166,10 @@ func remainingUnassigned(assigned [][]bool) int {
103166 return count104167 }105168 106-func chooseCageSize(maxCage, remaining int, rng *rand.Rand) int {169+func chooseCageSize(maxCage, remaining int, weights []int, rng *rand.Rand) int {107170 if remaining < maxCage {108171 maxCage = remaining109172 }110- weights := []int{0, 2, 4, 5, 4, 3, 2, 1, 1, 1}111173 totalWeight := 0112174 for size := 1; size <= maxCage; size++ {113175 totalWeight += weights[min(size, len(weights)-1)]@@ -126,7 +188,7 @@ func chooseCageSize(maxCage, remaining int, rng *rand.Rand) int {
126188 return 1127189 }128190 129-func growCageShape(start point, target int, assigned [][]bool, rng *rand.Rand) []point {191+func growCageShape(start point, target int, assigned [][]bool, profile generationProfile, rng *rand.Rand) []point {130192 shape := []point{start}131193 inShape := map[point]struct{}{start: {}}132194 height := len(assigned)@@ -153,7 +215,7 @@ func growCageShape(start point, target int, assigned [][]bool, rng *rand.Rand) [
153215 if len(frontier) == 0 {154216 break155217 }156- next := frontier[rng.IntN(len(frontier))]218+ next := chooseFrontierPoint(frontier, inShape, assigned, profile, rng)157219 shape = append(shape, next)158220 inShape[next] = struct{}{}159221 }@@ -161,6 +223,51 @@ func growCageShape(start point, target int, assigned [][]bool, rng *rand.Rand) [
161223 return shape162224 }163225 226+func chooseFrontierPoint(frontier []point, inShape map[point]struct{}, assigned [][]bool, profile generationProfile, rng *rand.Rand) point {227+ if len(frontier) == 1 || profile.shapeBias == shapeBiasNeutral {228+ return frontier[rng.IntN(len(frontier))]229+ }230+231+ sampleCount := min(profile.frontierSamples, len(frontier))232+ best := frontier[rng.IntN(len(frontier))]233+ bestScore := frontierScore(best, inShape, assigned)234+235+ for pick := 1; pick < sampleCount; pick++ {236+ candidate := frontier[rng.IntN(len(frontier))]237+ score := frontierScore(candidate, inShape, assigned)238+ if prefersFrontierScore(profile.shapeBias, score, bestScore) {239+ best = candidate240+ bestScore = score241+ }242+ }243+244+ return best245+}246+247+func frontierScore(candidate point, inShape map[point]struct{}, assigned [][]bool) int {248+ score := 0249+ for _, neighbor := range orthogonalNeighbors(candidate, len(assigned[0]), len(assigned)) {250+ if assigned[neighbor.y][neighbor.x] {251+ continue252+ }253+ if _, ok := inShape[neighbor]; ok {254+ score++255+ }256+ }257+ return score258+}259+260+func prefersFrontierScore(bias shapeBias, candidate, current int) bool {261+ switch bias {262+ case shapeBiasCompact:263+ return candidate > current264+ case shapeBiasWinding:265+ return candidate < current266+ default:267+ return false268+ }269+}270+164271 func generateSolution(geo *geometry, rng *rand.Rand) (grid, bool) {165272 state := newGrid(geo.width, geo.height)166273 if fillSolution(geo, state, rng) {@@ -229,17 +336,22 @@ func chooseNextFillCell(geo *geometry, state grid, rng *rand.Rand) (point, []int
229336 return best, bestCandidates, true230337 }231338 232-func removeClues(geo *geometry, solution grid, givenRatio float64, rng *rand.Rand) grid {339+func removeClues(geo *geometry, solution grid, givenRatio float64, profile generationProfile, rng *rand.Rand) grid {233340 givens := cloneGrid(solution)234341 totalCells := geo.width * geo.height235342 target := int(float64(totalCells) * givenRatio)236- if target < len(geo.cages) {237- target = len(geo.cages)238- }239343 240344 remainingByCage := make([]int, len(geo.cages))345+ minimumByCage := make([]int, len(geo.cages))346+ minimumTotal := 0241347 for cageIdx, cells := range geo.cageCells {242348 remainingByCage[cageIdx] = len(cells)349+ minimumByCage[cageIdx] = minGivensForCage(profile, len(cells))350+ minimumTotal += minimumByCage[cageIdx]351+ }352+353+ if target < minimumTotal {354+ target = minimumTotal243355 }244356 245357 cells := make([]point, 0, totalCells)@@ -259,7 +371,7 @@ func removeClues(geo *geometry, solution grid, givenRatio float64, rng *rand.Ran
259371 }260372 261373 cageIdx := geo.cageGrid[cell.y][cell.x]262- if remainingByCage[cageIdx] <= 1 {374+ if remainingByCage[cageIdx] <= minimumByCage[cageIdx] {263375 continue264376 }265377 @@ -276,3 +388,13 @@ func removeClues(geo *geometry, solution grid, givenRatio float64, rng *rand.Ran
276388 277389 return givens278390 }391+392+func minGivensForCage(profile generationProfile, cageSize int) int {393+ if cageSize <= 0 {394+ return 0395+ }396+ if cageSize >= len(profile.minGivensByCage) {397+ return max(1, profile.minGivensByCage[len(profile.minGivensByCage)-1])398+ }399+ return max(1, min(profile.minGivensByCage[cageSize], cageSize))400+}@@ -18,7 +18,7 @@ Fill each cage with the digits `1` through the cage size, then keep equal digits
1818 | `Backspace` / `Delete` | Clear cell |1919 | `Ctrl+H` | Toggle help bar |2020 | `Ctrl+R` | Reset puzzle |21-| `Ctrl+N` | Return to main menu |21+| `Escape` | Return to main menu |2222 2323 ## Tips2424 @@ -1,6 +1,7 @@
11 package rippleeffect22 33 import (4+ "fmt"45 "image"56 "image/color"67 "math/rand/v2"@@ -185,6 +186,67 @@ func TestGeneratePuzzleSeeded(t *testing.T) {
185186 }186187 }187188 189+func TestSampledGivenValueDistributionMatchesSolutions(t *testing.T) {190+ if testing.Short() {191+ t.Skip("skipping sampled ripple given-value distribution test in short mode")192+ }193+194+ sampleCounts := []int{8, 6, 4, 2, 1}195+ shareDriftTolerances := []float64{0.12, 0.10, 0.10, 0.12, 0.16}196+197+ for i, item := range Modes {198+ mode := item.(Mode)199+ samples := sampleCounts[i]200+ tolerance := shareDriftTolerances[i]201+202+ t.Run(fmt.Sprintf("%s-%d", mode.Title(), samples), func(t *testing.T) {203+ solutionCounts := make([]int, mode.MaxCage+1)204+ givenCounts := make([]int, mode.MaxCage+1)205+ totalSolutions := 0206+ totalGivens := 0207+208+ for sample := 0; sample < samples; sample++ {209+ rng := rand.New(rand.NewPCG(uint64(3000+i*37+sample), uint64(4000+i*41+sample)))210+ puzzle, err := mode.generatePuzzleSeeded(rng)211+ if err != nil {212+ t.Fatalf("generatePuzzleSeeded failed: %v", err)213+ }214+215+ totalSolutions += accumulateValueCounts(solutionCounts, puzzle.Solution)216+ totalGivens += accumulateValueCounts(givenCounts, puzzle.Givens)217+ }218+219+ if totalSolutions == 0 {220+ t.Fatal("expected sampled solutions to contain values")221+ }222+ if totalGivens == 0 {223+ t.Fatal("expected sampled puzzles to contain givens")224+ }225+226+ t.Logf("sampled solution values: %s", formatValueDistribution(solutionCounts))227+ t.Logf("sampled given values: %s", formatValueDistribution(givenCounts))228+229+ for value := 1; value < len(solutionCounts); value++ {230+ if solutionCounts[value] == 0 {231+ continue232+ }233+234+ solutionShare := float64(solutionCounts[value]) / float64(totalSolutions)235+ givenShare := float64(givenCounts[value]) / float64(totalGivens)236+ if diff := givenShare - solutionShare; diff < -tolerance || diff > tolerance {237+ t.Fatalf(238+ "value %d share %.2f outside %.2f +/- %.2f",239+ value,240+ givenShare,241+ solutionShare,242+ tolerance,243+ )244+ }245+ }246+ })247+ }248+}249+188250 func TestSaveRoundTrip(t *testing.T) {189251 puzzle := Puzzle{190252 Width: 3,@@ -565,3 +627,37 @@ func TestImportRejectsInvalidGivens(t *testing.T) {
565627 func pointCursor(x, y int) game.Cursor {566628 return game.Cursor{X: x, Y: y}567629 }630+631+func accumulateValueCounts(counts []int, state grid) int {632+ total := 0633+ for y := range len(state) {634+ for x := range len(state[y]) {635+ value := state[y][x]636+ if value <= 0 {637+ continue638+ }639+ counts[value]++640+ total++641+ }642+ }643+ return total644+}645+646+func formatValueDistribution(counts []int) string {647+ total := 0648+ for value := 1; value < len(counts); value++ {649+ total += counts[value]650+ }651+ if total == 0 {652+ return "none"653+ }654+655+ parts := make([]string, 0, len(counts)-1)656+ for value := 1; value < len(counts); value++ {657+ if counts[value] == 0 {658+ continue659+ }660+ parts = append(parts, fmt.Sprintf("%d=%d(%.1f%%)", value, counts[value], 100*float64(counts[value])/float64(total)))661+ }662+ return strings.Join(parts, ", ")663+}@@ -370,7 +370,7 @@ func junctionGapBackground(geo *geometry, backgrounds map[int]color.Color, x, y
370370 371371 func statusBarView(showFullHelp bool) string {372372 if showFullHelp {373- return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move ctrl+n: menu ctrl+r: reset ctrl+h: help")373+ return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move esc: menu ctrl+r: reset ctrl+h: help")374374 }375375 return game.StatusBarStyle().Render("1-9: place bkspc: clear")376376 }@@ -276,13 +276,15 @@ func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {
276276 }277277 }278278 case key.Matches(msg, m.keys.Delete):279- // Delete rectangle at cursor position.279+ // Backspace prioritizes dismissing transient previews before deleting a committed rectangle.280+ if m.mouseDragAnchor != nil || m.mousePreview != nil {281+ m = m.cancelPendingRectangle()282+ break283+ }280284 owner := m.puzzle.CellOwner(m.cursor.X, m.cursor.Y)281285 if owner >= 0 {282286 m.deleteRectangle(owner)283287 }284- case key.Matches(msg, m.keys.Cancel):285- m = m.cancelPendingRectangle()286288 default:287289 m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)288290 }@@ -337,11 +339,8 @@ func (m Model) handleExpansionMode(msg tea.KeyPressMsg) Model {
337339 m.commitRectangle(rect)338340 m.selectedClue = nil339341 }340- case key.Matches(msg, m.keys.Cancel):341- m = m.cancelPendingRectangle()342342 case key.Matches(msg, m.keys.Delete):343- m.deleteRectangle(clue.ID)344- m.selectedClue = nil343+ m = m.cancelPendingRectangle()345344 }346345 return m347346 }@@ -14,7 +14,7 @@ clue and the clue's value equals the rectangle's area.
1414 2. Press arrow keys to expand the rectangle in each direction.1515 3. Press `Shift+Arrow` to shrink a side if you overshot.1616 4. Press `Enter` or `Space` to confirm when the preview is green.17-5. Press `Escape` to cancel, or `Backspace` to delete.17+5. Press `Backspace` to cancel a draft or delete a placed rectangle.1818 1919 ## Controls2020 @@ -24,11 +24,11 @@ clue and the clue's value equals the rectangle's area.
2424 |-----|--------|2525 | Arrow keys / WASD / hjkl | Move cursor |2626 | `Enter` / `Space` | Select clue for expansion |27-| `Backspace` | Delete rectangle at cursor |27+| `Backspace` | Cancel preview or delete rectangle at cursor |2828 | `Ctrl+R` | Reset puzzle |2929 | `Ctrl+H` | Toggle full help |3030 | `Ctrl+E` | Toggle debug overlay |31-| `Ctrl+N` | Return to main menu |31+| `Escape` | Return to main menu |3232 3333 ### Expansion Mode (after selecting a clue)3434 @@ -37,8 +37,7 @@ clue and the clue's value equals the rectangle's area.
3737 | Arrow keys / WASD / hjkl | Expand rectangle in that direction |3838 | Shift+Arrow / WASD / HJKL | Shrink rectangle from that side |3939 | `Enter` / `Space` | Confirm placement (green preview only) |40-| `Escape` | Cancel, discard preview |41-| `Backspace` | Delete existing rectangle, return to nav |40+| `Backspace` | Cancel, discard preview |4241 4342 ## Modes4443 @@ -18,7 +18,7 @@ The puzzle is solved when all three rules hold simultaneously.
1818 |-----|--------|1919 | `Arrows` / `wasd` / `hjkl` | Move cursor |2020 | `Enter` / `Space` | Select clue to expand |21-| `Backspace` | Delete rectangle at cursor |21+| `Backspace` | Cancel preview or delete rectangle at cursor |2222 2323 ### Keyboard — Expansion Mode2424 @@ -30,8 +30,7 @@ rectangle is controlled independently.
3030 | `Arrow` / `wasd` / `hjkl` | Expand rectangle in that direction |3131 | `Shift+Arrow` / `WASD` / `HJKL` | Shrink rectangle from that side |3232 | `Enter` / `Space` | Confirm placement (if valid) |33-| `Escape` | Cancel, discard preview |34-| `Backspace` | Delete existing rectangle, return to nav |33+| `Backspace` | Cancel, discard preview |3534 3635 ### Mouse3736 @@ -52,7 +51,7 @@ Expansion Mode so you can fine-tune with the keyboard.
5251 |-----|--------|5352 | `Ctrl+R` | Reset puzzle |5453 | `Ctrl+H` | Toggle help bar |55-| `Ctrl+N` | Return to main menu |54+| `Escape` | Return to main menu |5655 5756 ## Tips5857 @@ -12,7 +12,6 @@ type KeyMap struct {
1212 ShrinkLeft key.Binding1313 ShrinkRight key.Binding1414 Select key.Binding15- Cancel key.Binding1615 Delete key.Binding1716 }1817 @@ -38,13 +37,9 @@ var DefaultKeyMap = KeyMap{
3837 key.WithKeys("enter", "space"),3938 key.WithHelp("enter/space", "Select/Confirm"),4039 ),41- Cancel: key.NewBinding(42- key.WithKeys("esc"),43- key.WithHelp("esc", "Cancel"),44- ),4540 Delete: key.NewBinding(46- key.WithKeys("backspace", "delete"),47- key.WithHelp("bkspc", "Delete"),41+ key.WithKeys("backspace"),42+ key.WithHelp("bkspc", "Cancel/Delete"),4843 ),4944 }5045 @@ -52,6 +47,6 @@ func (m Model) GetFullHelp() [][]key.Binding {
5247 return [][]key.Binding{5348 {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},5449 {m.keys.ShrinkUp, m.keys.ShrinkDown, m.keys.ShrinkLeft, m.keys.ShrinkRight},55- {m.keys.Select, m.keys.Cancel, m.keys.Delete},50+ {m.keys.Select, m.keys.Delete},5651 }5752 }@@ -1046,9 +1046,9 @@ func TestRectFromCorners(t *testing.T) {
10461046 }10471047 }10481048 1049-// --- Escape cancel behavior (P1) ---1049+// --- Backspace cancel behavior (P1) ---10501050 1051-func TestEscapeCancelsPendingExpansion(t *testing.T) {1051+func TestBackspaceCancelsPendingExpansion(t *testing.T) {10521052 p := simplePuzzle()10531053 selected := 010541054 m := Model{@@ -1061,15 +1061,15 @@ func TestEscapeCancelsPendingExpansion(t *testing.T) {
10611061 keys: DefaultKeyMap,10621062 }10631063 1064- next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})1064+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})10651065 got := next.(Model)10661066 10671067 if got.selectedClue != nil {1068- t.Fatal("expected selected clue to be cleared on escape")1068+ t.Fatal("expected selected clue to be cleared on backspace")10691069 }10701070 }10711071 1072-func TestEscapeCancelsPendingMousePreviewInNavMode(t *testing.T) {1072+func TestBackspaceCancelsPendingMousePreviewInNavMode(t *testing.T) {10731073 p := simplePuzzle()10741074 anchor := [2]int{0, 0}10751075 preview := Rectangle{X: 0, Y: 0, W: 2, H: 2}@@ -1080,14 +1080,14 @@ func TestEscapeCancelsPendingMousePreviewInNavMode(t *testing.T) {
10801080 mousePreview: &preview,10811081 }10821082 1083- next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})1083+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})10841084 got := next.(Model)10851085 10861086 if got.mouseDragAnchor != nil {1087- t.Fatal("expected mouse drag anchor to be cleared on escape")1087+ t.Fatal("expected mouse drag anchor to be cleared on backspace")10881088 }10891089 if got.mousePreview != nil {1090- t.Fatal("expected mouse preview to be cleared on escape")1090+ t.Fatal("expected mouse preview to be cleared on backspace")10911091 }10921092 }10931093 @@ -273,14 +273,14 @@ func infoView(puzzle *Puzzle) string {
273273 func statusBarView(selected, showFullHelp bool) string {274274 if selected {275275 if showFullHelp {276- 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")276+ return game.StatusBarStyle().Render("arrows: expand shift+arrows: shrink enter: confirm bkspc: cancel mouse: drag rect esc: menu ctrl+r: reset ctrl+h: help")277277 }278- return game.StatusBarStyle().Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel mouse: drag")278+ return game.StatusBarStyle().Render("arrows: expand shift+arrows: shrink enter: confirm bkspc: cancel mouse: drag")279279 }280280 if showFullHelp {281- 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")281+ return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select clue bkspc: cancel/delete mouse: click clue & drag esc: menu ctrl+r: reset ctrl+h: help")282282 }283- return game.StatusBarStyle().Render("enter/space: select clue bkspc: delete mouse: click & drag")283+ return game.StatusBarStyle().Render("enter/space: select clue bkspc: cancel/delete mouse: click & drag")284284 }285285 286286 func statusBarVariants() []string {@@ -241,7 +241,10 @@ func StaticHeight(cards []Card) int {
241241 // CardFullWidth is the rendered width of a single card including border + padding.242242 const CardFullWidth = CardInnerWidth + 4 // 34243243 244-const cardColumnGap = 1244+const (245+ cardColumnGap = 2246+ cardRowGap = 2247+)245248 246249 // ContentWidth returns the inner content width for the stats panel.247250 // It snaps to the widest whole-card grid that fits the available panel space.@@ -494,5 +497,5 @@ func renderCardRows(cards []Card, cols int) string {
494497 rows = append(rows, row)495498 }496499 497- return lipgloss.JoinVertical(lipgloss.Left, rows...)500+ return strings.Join(rows, strings.Repeat("\n", cardRowGap+1))498501 }@@ -370,7 +370,13 @@ func TestRenderCardGridWrapsRowsByWidth(t *testing.T) {
370370 if got := strings.Count(lines[0], "╭"); got != 3 {371371 t.Fatalf("first row cards = %d, want 3", got)372372 }373- if got := strings.Count(lines[CardHeight], "╭"); got != 2 {373+ secondRowStart := CardHeight + cardRowGap374+ for i := CardHeight; i < secondRowStart; i++ {375+ if lines[i] != "" {376+ t.Fatalf("row gap line %d = %q, want blank", i, lines[i])377+ }378+ }379+ if got := strings.Count(lines[secondRowStart], "╭"); got != 2 {374380 t.Fatalf("second row cards = %d, want 2", got)375381 }376382 if got := maxRenderedWidth(rendered); got > width {@@ -19,7 +19,7 @@ Fill a 9x9 grid with digits so every row, column, and box is complete.
1919 | `Backspace` | Clear cell |2020 | `Ctrl+H` | Toggle help bar |2121 | `Ctrl+R` | Reset puzzle |22-| `Ctrl+N` | Return to main menu |22+| `Escape` | Return to main menu |2323 2424 ## Tips2525 @@ -253,7 +253,7 @@ func computeConflicts(g grid) [gridSize][gridSize]bool {
253253 254254 func statusBarView(showFullHelp bool) string {255255 if showFullHelp {256- return game.StatusBarStyle().Render("arrows/wasd: move 1-9: fill bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")256+ return game.StatusBarStyle().Render("arrows/wasd: move 1-9: fill bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")257257 }258258 return game.StatusBarStyle().Render("1-9: fill bkspc: clear")259259 }@@ -28,7 +28,7 @@ every cell is filled and all three rules are satisfied.
2828 | `Ctrl+R` | Reset puzzle |2929 | `Ctrl+H` | Toggle full help |3030 | `Ctrl+E` | Toggle debug overlay |31-| `Ctrl+N` | Return to main menu |31+| `Escape` | Return to main menu |3232 3333 ## Modes3434 @@ -22,7 +22,7 @@ solved when every cell is filled and all three rules are satisfied.
2222 | `Backspace` | Clear cell |2323 | `Ctrl+H` | Toggle help bar |2424 | `Ctrl+R` | Reset puzzle |25-| `Ctrl+N` | Return to main menu |25+| `Escape` | Return to main menu |2626 2727 ## Tips2828 @@ -107,7 +107,7 @@ func gridView(m Model) string {
107107 108108 func statusBarView(showFullHelp bool) string {109109 if showFullHelp {110- return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z: ● x: ○ bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")110+ return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z: ● x: ○ bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")111111 }112112 return game.StatusBarStyle().Render("mouse: click/cycle z: ● x: ○")113113 }@@ -22,4 +22,4 @@ Pre-filled cells are locked. Relation clues are also fixed and cannot be edited.
2222 | `Backspace` | Clear cell |2323 | `Ctrl+H` | Toggle help bar |2424 | `Ctrl+R` | Reset puzzle |25-| `Ctrl+N` | Return to main menu |25+| `Escape` | Return to main menu |@@ -168,7 +168,7 @@ func relationStateBackground(state int) color.Color {
168168 169169 func statusBarView(showFullHelp bool) string {170170 if showFullHelp {171- return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z: ● x: ○ bkspc: clear =: same clue x: opposite clue ctrl+n: menu ctrl+r: reset ctrl+h: help")171+ return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z: ● x: ○ bkspc: clear =: same clue x: opposite clue esc: menu ctrl+r: reset ctrl+h: help")172172 }173173 return game.StatusBarStyle().Render("mouse: click/cycle z: ● x: ○ fixed clues: = and x")174174 }@@ -13,7 +13,7 @@ The puzzle is solved when all words are found.
1313 1414 1. Move the cursor to the start of a word and press `Enter` or `Space`.1515 2. Navigate to the end of the word.16-3. Press `Enter` or `Space` to confirm, or `Escape` to cancel.16+3. Press `Enter` or `Space` to confirm, or `Backspace` to cancel.1717 1818 ## Controls1919 @@ -21,11 +21,11 @@ The puzzle is solved when all words are found.
2121 |-----|--------|2222 | Arrow keys / WASD / hjkl | Move cursor |2323 | `Enter` / `Space` | Select start or confirm end |24-| `Escape` | Cancel current selection |24+| `Backspace` | Cancel current selection |2525 | `Ctrl+R` | Reset puzzle |2626 | `Ctrl+H` | Toggle full help |2727 | `Ctrl+E` | Toggle debug overlay |28-| `Ctrl+N` | Return to main menu |28+| `Escape` | Return to main menu |2929 3030 ## Modes3131 @@ -11,7 +11,7 @@ Find hidden words in a letter grid.
1111 1212 To select a word, move to its first letter and press `Enter` or `Space`,1313 navigate to the last letter, then press `Enter` or `Space` to confirm.14-Press `Escape` to cancel a selection.14+Press `Backspace` to cancel a selection.1515 1616 ## Controls1717 @@ -21,10 +21,10 @@ Press `Escape` to cancel a selection.
2121 |-----|--------|2222 | `Arrows` / `wasd` / `hjkl` | Move cursor |2323 | `Enter` / `Space` | Select start or confirm end |24-| `Escape` | Cancel current selection |24+| `Backspace` | Cancel current selection |2525 | `Ctrl+H` | Toggle help bar |2626 | `Ctrl+R` | Reset puzzle |27-| `Ctrl+N` | Return to main menu |27+| `Escape` | Return to main menu |2828 2929 ### Mouse3030 @@ -111,9 +111,9 @@ func renderView(m Model) string {
111111 112112 func statusBarView(showFullHelp bool) string {113113 if showFullHelp {114- return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select esc: cancel mouse: click & drag ctrl+n: menu ctrl+r: reset ctrl+h: help")114+ return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select bkspc: cancel mouse: click & drag esc: menu ctrl+r: reset ctrl+h: help")115115 }116- return game.StatusBarStyle().Render("enter/space: select esc: cancel mouse: click & drag")116+ return game.StatusBarStyle().Render("enter/space: select bkspc: cancel mouse: click & drag")117117 }118118 119119 func renderGrid(m Model) string {@@ -5,6 +5,7 @@ import (
55 "math/rand/v2"66 "testing"77 8+ tea "charm.land/bubbletea/v2"89 "github.com/FelineStateMachine/puzzletea/game"910 )1011 @@ -128,6 +129,23 @@ func TestWordPositions(t *testing.T) {
128129 })129130 }130131 132+func TestBackspaceCancelsSelection(t *testing.T) {133+ m := Model{134+ width: 3,135+ height: 3,136+ grid: createEmptyGrid(3, 3),137+ selection: startSelected,138+ keys: DefaultKeyMap,139+ foundCells: buildFoundCells(3, 3, nil),140+ }141+142+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})143+ got := next.(Model)144+ if got.selection != noSelection {145+ t.Fatal("expected backspace to clear the current selection")146+ }147+}148+131149 // --- Grid operations (P1) ---132150 133151 func TestGridGetSet(t *testing.T) {