Compare commits

..
10 Commits
Author SHA1 Message Date
cjdenio 1f208f80b7 update readme 2026-10-03 10:52:41 -06:00
cjdenio 19e295493e oops 2026-10-03 10:50:02 -06:00
cjdenio 6242535184 note + fix bug 2026-10-03 10:46:20 -06:00
cjdenio d50b572fb5 license 2026-10-02 09:11:04 -06:00
cjdenio 7b35f86e32 fix issues 2026-10-01 16:07:45 -06:00
cjdenio 419f0ae3cf handle empty copyright 2026-10-01 10:40:54 -06:00
cjdenio b918c5be23 parse rebus cells 2026-09-29 22:50:24 -04:00
cjdenio 60d1bd644c reveal/check/clear 2026-09-29 22:26:49 -04:00
cjdenio 50be9ab605 refactor .puz parser + detect rebuses 2026-09-29 21:20:13 -04:00
cjdenio b7c64dabe3 fix ipuz bug 2026-09-29 14:39:48 -04:00
7 changed files with 376 additions and 63 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Caleb Denio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -4
View File
@@ -2,7 +2,7 @@ Very silly terminal-based crossword solver. It can play .puz / .ipuz files from
- Requirements: [Go](https://go.dev) - Requirements: [Go](https://go.dev)
- Build: `make` - Build: `make`
- Usage: `./bin/crossword <file>.puz` - Usage: `./bin/crossword <file>.{ipuz,puz}`
![demo](demo.gif) ![demo](demo.gif)
@@ -11,9 +11,6 @@ Very silly terminal-based crossword solver. It can play .puz / .ipuz files from
todo todo
- timer - timer
- reveal grid/word
- .puz checksums - .puz checksums
- fix shift+enter / shift+tab in other terminals - fix shift+enter / shift+tab in other terminals
- clear grid
- rebus - rebus
- detect rebus for .puz files
+144 -27
View File
@@ -20,16 +20,27 @@ const (
Solved Solved
) )
type UIState int
const (
StateGame UIState = iota
StateRebusConfirmation
StateMenu
StateRevealMenu
StateCheckMenu
)
type State struct { type State struct {
Puzzle *puz.Puzzle Puzzle *puz.Puzzle
PuzzleState []rune PuzzleState []rune
SelectedCell int SelectedCell int
SelectedClue *puz.Clue SelectedClue *puz.Clue
LastKeySequence string LastKeySequence string
DebugMode bool DebugMode bool
SolveState SolveState SolveState SolveState
CheckState []rune CheckState []rune
RebusConfirmation bool UIState UIState
NoteVisible bool
} }
func RenderWithLineWrap(s string, width int) (out string, lines int) { func RenderWithLineWrap(s string, width int) (out string, lines int) {
@@ -74,13 +85,17 @@ func RenderWithLineWrap(s string, width int) (out string, lines int) {
} }
func NewState(puzzle *puz.Puzzle) *State { func NewState(puzzle *puz.Puzzle) *State {
state := StateGame
if puzzle.HasRebus {
state = StateRebusConfirmation
}
return &State{ return &State{
Puzzle: puzzle, Puzzle: puzzle,
PuzzleState: []rune(puzzle.State), PuzzleState: []rune(puzzle.State),
SelectedClue: puzzle.Clues[0], SelectedClue: puzzle.Clues[0],
SelectedCell: puzzle.Clues[0].Cells[0], SelectedCell: puzzle.Clues[0].Cells[0],
CheckState: make([]rune, len(puzzle.State)), CheckState: make([]rune, len(puzzle.State)),
RebusConfirmation: puzzle.HasRebus, UIState: state,
} }
} }
@@ -284,8 +299,25 @@ func (state *State) CheckWord(clue *puz.Clue) {
} }
} }
func (state *State) RevealWord(clue *puz.Clue) {
for i, cellIdx := range clue.Cells {
state.PuzzleState[cellIdx] = rune(clue.Solution[i])
state.CheckState[cellIdx] = 'y'
}
}
func (state *State) RevealPuzzle() {
for i, cell := range state.PuzzleState {
if cell == '.' {
continue
}
state.PuzzleState[i] = rune(state.Puzzle.Solution[i])
state.CheckState[i] = 'y'
}
}
func (state *State) RenderUI(w io.Writer, width int) int { func (state *State) RenderUI(w io.Writer, width int) int {
if state.RebusConfirmation { if state.UIState == StateRebusConfirmation {
fmt.Fprint(w, "This puzzle contains a rebus. This tool does not support rebuses.\r\nPress 'y' to continue anyway, or press any other key to exit.\r\n") fmt.Fprint(w, "This puzzle contains a rebus. This tool does not support rebuses.\r\nPress 'y' to continue anyway, or press any other key to exit.\r\n")
return 2 return 2
} }
@@ -296,8 +328,17 @@ func (state *State) RenderUI(w io.Writer, width int) int {
uiHeight += 2 uiHeight += 2
fmt.Fprintf(w, "AUTHOR: %s\r\n", state.Puzzle.Author) fmt.Fprintf(w, "AUTHOR: %s\r\n", state.Puzzle.Author)
uiHeight += 1 uiHeight += 1
if state.Puzzle.Note != "" && state.NoteVisible {
note, noteLines := RenderWithLineWrap(state.Puzzle.Note, min(width, 60))
fmt.Fprintf(w, "\r\n%s\r\n", AnsiDimmed(note))
uiHeight += noteLines + 2
}
fmt.Fprint(w, state.RenderPuzzle()+"\r\n") fmt.Fprint(w, state.RenderPuzzle()+"\r\n")
uiHeight += state.Puzzle.Height + 3 uiHeight += state.Puzzle.Height + 3
if state.SelectedClue != nil { if state.SelectedClue != nil {
directionStr := "down" directionStr := "down"
if state.SelectedClue.Direction == puz.DirectionAcross { if state.SelectedClue.Direction == puz.DirectionAcross {
@@ -310,8 +351,30 @@ func (state *State) RenderUI(w io.Writer, width int) int {
io.WriteString(w, "\r\n") io.WriteString(w, "\r\n")
uiHeight += 1 uiHeight += 1
} }
fmt.Fprintf(w, "%s\r\n", state.Puzzle.Copyright) if state.Puzzle.Copyright != "" {
uiHeight += 1 fmt.Fprintf(w, "%s\r\n\r\n", state.Puzzle.Copyright)
uiHeight += 2
}
switch state.UIState {
case StateGame:
line := "[enter]/[tab]: next word | [ctrl+a]: menu"
if state.Puzzle.Note != "" {
line += " | [ctrl+n]: toggle note"
}
line += " | [ctrl+c]: exit\r\n"
fmt.Fprint(w, AnsiDimmed(line))
uiHeight += 1
case StateMenu:
fmt.Fprint(w, AnsiDimmed("[c]: check puzzle/word | [r]: reveal puzzle/word | [x]: clear grid | [q]: exit menu\r\n"))
uiHeight += 1
case StateCheckMenu:
fmt.Fprint(w, AnsiDimmed("[w]: check word | [p]: check puzzle | [q]: exit menu\r\n"))
uiHeight += 1
case StateRevealMenu:
fmt.Fprint(w, AnsiDimmed("[w]: reveal word | [p]: reveal puzzle | [q]: exit menu\r\n"))
uiHeight += 1
}
switch state.SolveState { switch state.SolveState {
case FilledNotSolved: case FilledNotSolved:
@@ -471,26 +534,78 @@ func (state *State) SwitchDirections() bool {
return false return false
} }
func (state *State) ClearGrid() {
for i, cell := range state.PuzzleState {
if cell != '.' {
state.PuzzleState[i] = '-'
state.CheckState[i] = '\x00'
}
}
}
func (state *State) HandleInput(buffer []byte) (exited bool) { func (state *State) HandleInput(buffer []byte) (exited bool) {
if len(buffer) == 0 { if len(buffer) == 0 {
return false return false
} }
if state.RebusConfirmation {
if buffer[0] == 'y' {
state.RebusConfirmation = false
return false
} else {
return true
}
}
state.LastKeySequence = fmt.Sprintf("%v", buffer) state.LastKeySequence = fmt.Sprintf("%v", buffer)
if buffer[0] == 3 { if buffer[0] == 3 {
return true return true
} }
switch state.UIState {
case StateRebusConfirmation:
if buffer[0] == 'y' {
state.UIState = StateGame
return false
} else {
return true
}
case StateMenu:
switch buffer[0] {
case 'c':
state.UIState = StateCheckMenu
case 'r':
state.UIState = StateRevealMenu
case 'x':
state.ClearGrid()
state.UIState = StateGame
default:
state.UIState = StateGame
}
return false
case StateCheckMenu:
switch buffer[0] {
case 'w':
state.CheckWord(state.SelectedClue)
case 'p':
state.CheckPuzzle()
}
state.UIState = StateGame
return false
case StateRevealMenu:
switch buffer[0] {
case 'w':
state.RevealWord(state.SelectedClue)
case 'p':
state.RevealPuzzle()
return true
}
state.UIState = StateGame
return false
}
if buffer[0] == '\x01' {
state.UIState = StateMenu
return false
}
if buffer[0] == '\x0E' {
state.NoteVisible = !state.NoteVisible
return false
}
if buffer[0] == ' ' { if buffer[0] == ' ' {
state.SwitchDirections() state.SwitchDirections()
} }
@@ -533,10 +648,12 @@ func (state *State) HandleInput(buffer []byte) (exited bool) {
// is there a filled cell underneath the cursor? // is there a filled cell underneath the cursor?
if state.PuzzleState[state.SelectedCell] != '-' { if state.PuzzleState[state.SelectedCell] != '-' {
state.PuzzleState[state.SelectedCell] = '-' state.PuzzleState[state.SelectedCell] = '-'
state.CheckState[state.SelectedCell] = 0x00
} else { } else {
i := slices.Index(state.SelectedClue.Cells, state.SelectedCell) i := slices.Index(state.SelectedClue.Cells, state.SelectedCell)
if i > 0 { if i > 0 {
state.PuzzleState[state.SelectedClue.Cells[i-1]] = '-' // clear the previous cell state.PuzzleState[state.SelectedClue.Cells[i-1]] = '-' // clear the previous cell
state.CheckState[state.SelectedClue.Cells[i-1]] = 0x00
state.SelectedCell = state.SelectedClue.Cells[i-1] state.SelectedCell = state.SelectedClue.Cells[i-1]
} }
} }
+37 -14
View File
@@ -21,12 +21,34 @@ type iPuzFile struct {
Title string `json:"title"` Title string `json:"title"`
Author string `json:"author"` Author string `json:"author"`
Copyright string `json:"copyright"` Copyright string `json:"copyright"`
Notes string `json:"notes"`
Puzzle [][]any `json:"puzzle"` Puzzle [][]any `json:"puzzle"`
Solution [][]string `json:"solution"` Solution [][]string `json:"solution"`
Dimensions iPuzDimensions `json:"dimensions"` Dimensions iPuzDimensions `json:"dimensions"`
Clues map[string][]iPuzClue `json:"clues"` Clues map[string][]iPuzClue `json:"clues"`
} }
func unwrapCell(cell any) (any, error) {
switch c := cell.(type) {
case string:
if c == "#" {
return "#", nil
} else if i, err := strconv.Atoi(c); err == nil {
return i, nil
} else {
return nil, errors.New("invalid string")
}
case float64:
return int(c), nil
case map[string]any:
if unwrappedCell, ok := c["cell"]; ok {
return unwrapCell(unwrappedCell) // recursion babyyyyyy
}
}
return nil, errors.New("unknown type")
}
func ParseIPuz(file []byte) (*Puzzle, error) { func ParseIPuz(file []byte) (*Puzzle, error) {
var parsed iPuzFile var parsed iPuzFile
err := json.Unmarshal(file, &parsed) err := json.Unmarshal(file, &parsed)
@@ -42,9 +64,12 @@ func ParseIPuz(file []byte) (*Puzzle, error) {
puzzle := new(Puzzle) puzzle := new(Puzzle)
puzzle.RebusCells = make(map[int]string)
puzzle.Title = parsed.Title puzzle.Title = parsed.Title
puzzle.Author = parsed.Author puzzle.Author = parsed.Author
puzzle.Copyright = parsed.Copyright puzzle.Copyright = parsed.Copyright
puzzle.Note = parsed.Notes
puzzle.Width = parsed.Dimensions.Width puzzle.Width = parsed.Dimensions.Width
puzzle.Height = parsed.Dimensions.Height puzzle.Height = parsed.Dimensions.Height
@@ -56,33 +81,31 @@ func ParseIPuz(file []byte) (*Puzzle, error) {
state := strings.Builder{} state := strings.Builder{}
for y, row := range parsed.Puzzle { for y, row := range parsed.Puzzle {
for x, cell := range row { for x, cell := range row {
switch c := cell.(type) { unwrappedCell, err := unwrapCell(cell)
if err != nil {
return nil, err
}
switch c := unwrappedCell.(type) {
case string: case string:
if c == "#" { state.WriteRune('.')
state.WriteRune('.') case int:
} else { clueToCellMap[c] = [2]int{x, y}
if i, err := strconv.Atoi(c); err == nil {
clueToCellMap[i] = [2]int{x, y}
}
state.WriteRune('-')
}
case float64:
clueToCellMap[int(c)] = [2]int{x, y}
state.WriteRune('-') state.WriteRune('-')
default: default:
state.WriteRune('-') return nil, errors.New("unexpected type in .ipuz file")
} }
} }
} }
puzzle.State = state.String() puzzle.State = state.String()
solution := strings.Builder{} solution := strings.Builder{}
for _, row := range parsed.Solution { for y, row := range parsed.Solution {
for _, cell := range row { for x, cell := range row {
if cell == "#" { if cell == "#" {
solution.WriteRune('.') solution.WriteRune('.')
} else if len(cell) > 1 { } else if len(cell) > 1 {
solution.WriteByte(cell[0]) solution.WriteByte(cell[0])
puzzle.RebusCells[y*puzzle.Width+x] = cell
puzzle.HasRebus = true puzzle.HasRebus = true
} else if len(cell) == 1 { } else if len(cell) == 1 {
solution.WriteString(cell) solution.WriteString(cell)
+154 -14
View File
@@ -5,7 +5,10 @@ import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"errors" "errors"
"io"
"math" "math"
"regexp"
"strconv"
"strings" "strings"
"golang.org/x/text/encoding/charmap" "golang.org/x/text/encoding/charmap"
@@ -61,33 +64,128 @@ func readText(b *bufio.Reader, version string) (string, error) {
} }
} }
type ExtraBlock struct {
Name string
Data []byte
}
func readExtraBlock(r *bufio.Reader) (*ExtraBlock, error) {
name := make([]byte, 4)
_, err := r.Read(name)
if err != nil {
return nil, err
}
length := make([]byte, 2)
_, err = r.Read(length)
if err != nil {
return nil, err
}
_, err = r.Discard(2)
if err != nil {
return nil, err
}
data := make([]byte, binary.LittleEndian.Uint16(length))
_, err = r.Read(data)
if err != nil {
return nil, err
}
// consume trailing null byte
_, err = r.Discard(1)
if err != nil {
return nil, err
}
return &ExtraBlock{
Name: string(name),
Data: data,
}, nil
}
func ParsePuz(file []byte) (*Puzzle, error) { func ParsePuz(file []byte) (*Puzzle, error) {
r := bytes.NewReader(file)
_, err := r.Seek(2, io.SeekStart) // consume the checksum
if err != nil {
return nil, err
}
// read magic bytes // read magic bytes
magic := string(file[2:14]) magic := make([]byte, 12)
if magic != "ACROSS&DOWN\x00" { _, err = r.Read(magic)
if err != nil {
return nil, err
}
if !bytes.Equal(magic, []byte("ACROSS&DOWN\x00")) {
return nil, errors.New("not a valid .puz file") return nil, errors.New("not a valid .puz file")
} }
puzzle := new(Puzzle) puzzle := new(Puzzle)
puzzle.RebusCells = make(map[int]string)
_, err = r.Seek(10, io.SeekCurrent)
if err != nil {
return nil, err
}
// read header // read header
version := string(file[0x18:0x1B]) version := make([]byte, 4)
puzzle.Width = int(file[0x2C]) _, err = r.Read(version)
puzzle.Height = int(file[0x2D]) if err != nil {
puzzle.ClueCount = int(binary.LittleEndian.Uint16(file[0x2E:0x30])) return nil, err
}
_, err = r.Seek(16, io.SeekCurrent)
if err != nil {
return nil, err
}
width, err := r.ReadByte()
if err != nil {
return nil, err
}
height, err := r.ReadByte()
if err != nil {
return nil, err
}
puzzle.Width = int(width)
puzzle.Height = int(height)
clueCount := make([]byte, 2)
_, err = r.Read(clueCount)
if err != nil {
return nil, err
}
puzzle.ClueCount = int(binary.LittleEndian.Uint16(clueCount))
puzzleSize := puzzle.Width * puzzle.Height puzzleSize := puzzle.Width * puzzle.Height
solutionStart := 0x34
solutionEnd := 0x34 + puzzleSize
stateStart := solutionEnd _, err = r.Seek(4, io.SeekCurrent)
stateEnd := stateStart + puzzleSize if err != nil {
return nil, err
}
puzzle.Solution = string(file[solutionStart:solutionEnd]) solution := make([]byte, puzzleSize)
puzzle.State = string(file[stateStart:stateEnd]) _, err = r.Read(solution)
if err != nil {
return nil, err
}
state := make([]byte, puzzleSize)
_, err = r.Read(state)
if err != nil {
return nil, err
}
// read text puzzle.Solution = string(solution)
buf := bufio.NewReader(bytes.NewReader(file[stateEnd:])) puzzle.State = string(state)
// read text fields
buf := bufio.NewReader(r)
title, err := readText(buf, string(version[0])) title, err := readText(buf, string(version[0]))
if err != nil { if err != nil {
@@ -117,6 +215,48 @@ func ParsePuz(file []byte) (*Puzzle, error) {
clues = append(clues, clue) clues = append(clues, clue)
} }
note, err := readText(buf, string(version[0]))
if err != nil {
return nil, err
}
puzzle.Note = note
extraBlocks := make(map[string]*ExtraBlock)
for {
extra, err := readExtraBlock(buf)
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return nil, err
}
extraBlocks[extra.Name] = extra
}
grbs, grbsOk := extraBlocks["GRBS"]
trbl, trblOk := extraBlocks["RTBL"]
if grbsOk && trblOk {
puzzle.HasRebus = true
rebusCellMap := make(map[int][]int)
for i, b := range grbs.Data {
if b > 0 {
rebusCellMap[int(b)-1] = append(rebusCellMap[int(b)-1], i)
}
}
rebusData := regexp.MustCompile(`\s*(\d+):(\w+);\s*`).FindAllStringSubmatch(string(trbl.Data), -1)
for _, rebus := range rebusData {
cell, err := strconv.Atoi(rebus[1])
if err != nil {
continue
}
for _, c := range rebusCellMap[cell] {
puzzle.RebusCells[c] = rebus[2]
}
}
}
// assign clue numbers // assign clue numbers
puzzle.Clues = make([]*Clue, 0, puzzle.ClueCount) puzzle.Clues = make([]*Clue, 0, puzzle.ClueCount)
puzzle.Cells = make([][2]*Clue, len(puzzle.Solution)) puzzle.Cells = make([][2]*Clue, len(puzzle.Solution))
+3
View File
@@ -29,6 +29,7 @@ type Puzzle struct {
Title string Title string
Author string Author string
Copyright string Copyright string
Note string
Solution string Solution string
State string State string
@@ -38,6 +39,8 @@ type Puzzle struct {
Cells [][2]*Clue Cells [][2]*Clue
HasRebus bool HasRebus bool
RebusCells map[int]string
} }
func LoadPuzzle(r io.Reader) (*Puzzle, error) { func LoadPuzzle(r io.Reader) (*Puzzle, error) {
+16 -4
View File
@@ -39,8 +39,20 @@ func main() {
io.WriteString(s, "\x1b[?25h") io.WriteString(s, "\x1b[?25h")
}() }()
pty, _, _ := s.Pty() // TODO: actually handle terminal size changes pty, winCh, isPty := s.Pty()
uiHeight := state.RenderUI(s, pty.Window.Width) if !isPty {
return
}
windowWidth := pty.Window.Width
uiHeight := state.RenderUI(s, windowWidth)
go func() {
for window := range winCh {
windowWidth = window.Width
}
}()
scanner := bufio.NewScanner(s) scanner := bufio.NewScanner(s)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
@@ -72,14 +84,14 @@ func main() {
state.SelectedCell = -1 state.SelectedCell = -1
fmt.Fprintf(s, "\r\x1b[%dA", uiHeight) fmt.Fprintf(s, "\r\x1b[%dA", uiHeight)
fmt.Fprint(s, "\x1b[J") fmt.Fprint(s, "\x1b[J")
state.RenderUI(s, pty.Window.Width) state.RenderUI(s, windowWidth)
break break
} }
fmt.Fprintf(s, "\r\x1b[%dA", uiHeight) fmt.Fprintf(s, "\r\x1b[%dA", uiHeight)
fmt.Fprint(s, "\x1b[J") fmt.Fprint(s, "\x1b[J")
uiHeight = state.RenderUI(s, pty.Window.Width) uiHeight = state.RenderUI(s, windowWidth)
} }
log.Printf("%s disconnected\n", s.RemoteAddr().String()) log.Printf("%s disconnected\n", s.RemoteAddr().String())