├── cmd └── sku │ ├── .version │ ├── get_version.sh │ └── main.go ├── go.work ├── go.work.sum ├── internal └── model │ ├── components │ ├── help │ │ ├── style.go │ │ └── help.go │ ├── stopwatch │ │ ├── style.go │ │ └── stopwatch.go │ ├── animate │ │ ├── style.go │ │ └── animate.go │ ├── keys │ │ └── keys.go │ └── board │ │ ├── style.go │ │ └── board.go │ └── model.go ├── Dockerfile ├── .gitignore ├── .github └── workflows │ ├── go.yaml │ ├── aur.yaml │ ├── version.yaml │ └── deploy.yaml ├── Makefile ├── go.mod ├── template └── archlinux │ └── PKGBUILD ├── pkg └── sudoku │ ├── sudoku_test.go │ ├── README.md │ └── sudoku.go ├── license ├── README.md └── go.sum /cmd/sku/.version: -------------------------------------------------------------------------------- 1 | r6.971f844 -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.18 2 | 3 | use . 4 | -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0= 2 | -------------------------------------------------------------------------------- /cmd/sku/get_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" > .version 4 | -------------------------------------------------------------------------------- /internal/model/components/help/style.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | helpStyle = lipgloss.NewStyle().Height(2) 7 | ) 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18-alpine AS builder 2 | WORKDIR /app 3 | COPY . /app 4 | RUN go generate ./... 5 | RUN go build -o sku /app/cmd/sku/main.go 6 | 7 | FROM alpine:3.15 8 | COPY --from=builder /app/sku / 9 | CMD ["/sku"] 10 | -------------------------------------------------------------------------------- /internal/model/components/stopwatch/style.go: -------------------------------------------------------------------------------- 1 | package stopwatch 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | stopwatchStyle = lipgloss.NewStyle(). 7 | Foreground(lipgloss.Color("8")). 8 | Margin(0, 0, 1, 0) 9 | ) 10 | -------------------------------------------------------------------------------- /internal/model/components/animate/style.go: -------------------------------------------------------------------------------- 1 | package animate 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | spriteStyle = lipgloss.NewStyle(). 7 | Foreground(lipgloss.Color("0")). 8 | Background(lipgloss.Color("5")). 9 | Italic(true) 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | sku -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go CI 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | pull_request: 7 | branches: ['*'] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.18 20 | 21 | - name: Build cli 22 | run: make 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | go generate ./... 3 | go build -o sku cmd/sku/main.go 4 | 5 | run: 6 | go run cmd/sku/main.go 7 | 8 | clean: 9 | @if [ -f sku ] && [ -x sku ]; then \ 10 | rm sku; \ 11 | fi 12 | 13 | docker-build: 14 | docker build -t sku . 15 | 16 | docker-run: 17 | docker run -it -e "TERM=xterm-256color" sku 18 | 19 | install: 20 | go generate ./... 21 | go build -o sku cmd/sku/main.go 22 | mv -f sku `go env GOPATH`/bin/ 23 | 24 | uninstall: 25 | rm `go env GOPATH`/bin/sku 26 | -------------------------------------------------------------------------------- /internal/model/components/stopwatch/stopwatch.go: -------------------------------------------------------------------------------- 1 | package stopwatch 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/bubbles/stopwatch" 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | type Model struct { 11 | stopwatch stopwatch.Model 12 | } 13 | 14 | func NewModel() Model { 15 | return Model{ 16 | stopwatch: stopwatch.NewWithInterval(time.Second), 17 | } 18 | } 19 | 20 | func (m *Model) Init() tea.Cmd { 21 | return m.stopwatch.Init() 22 | } 23 | 24 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 25 | var cmd tea.Cmd 26 | m.stopwatch, cmd = m.stopwatch.Update(msg) 27 | return m, cmd 28 | } 29 | 30 | func (m Model) View() string { 31 | return stopwatchStyle.Render("Elapsed time: " + m.stopwatch.View()) 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/aur.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package to AUR 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Update package version"] 6 | branches: ["master"] 7 | types: ["completed"] 8 | 9 | jobs: 10 | publish-to-aur: 11 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Publish to the AUR 18 | uses: KSXGitHub/github-actions-deploy-aur@v2.2.5 19 | with: 20 | pkgname: sku-git 21 | pkgbuild: ./template/archlinux/PKGBUILD 22 | commit_username: ${{ secrets.AUR_USERNAME }} 23 | commit_email: ${{ secrets.AUR_EMAIL }} 24 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 25 | -------------------------------------------------------------------------------- /internal/model/components/help/help.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/key" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/fedeztk/sku/internal/model/components/keys" 8 | ) 9 | 10 | type Model struct { 11 | help help.Model 12 | keys keys.KeyMap 13 | } 14 | 15 | func NewModel() Model { 16 | return Model{ 17 | help: help.NewModel(), 18 | keys: keys.Keys, 19 | } 20 | } 21 | 22 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 23 | switch msg := msg.(type) { 24 | case tea.KeyMsg: 25 | switch { 26 | case key.Matches(msg, m.keys.Help): 27 | m.help.ShowAll = !m.help.ShowAll 28 | } 29 | 30 | case tea.WindowSizeMsg: 31 | m.help.Width = msg.Width 32 | } 33 | 34 | return m, nil 35 | } 36 | 37 | func (m Model) View() string { 38 | return helpStyle.Render(m.help.View(m.keys)) 39 | } 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fedeztk/sku 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v0.20.0 7 | github.com/charmbracelet/harmonica v0.1.0 8 | ) 9 | 10 | require github.com/charmbracelet/lipgloss v0.5.0 11 | 12 | require ( 13 | github.com/charmbracelet/bubbles v0.10.3 14 | github.com/containerd/console v1.0.3 // indirect 15 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 16 | github.com/mattn/go-isatty v0.0.14 // indirect 17 | github.com/mattn/go-runewidth v0.0.13 // indirect 18 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect 19 | github.com/muesli/reflow v0.3.0 // indirect 20 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect 21 | github.com/rivo/uniseg v0.2.0 // indirect 22 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect 23 | golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /template/archlinux/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Federico Serra 2 | 3 | pkgname=sku-git 4 | _name=sku 5 | pkgver=r6.971f844 6 | pkgrel=1 7 | pkgdesc="Simple TUI sudoku written in go" 8 | arch=('any') 9 | url="https://github.com/fedeztk/sku" 10 | license=('MIT') 11 | depends=('glibc') 12 | makedepends=('go' 'git') 13 | provides=('sku') 14 | conflicts=('sku') 15 | source=('git+https://github.com/fedeztk/sku.git') 16 | sha256sums=('SKIP') 17 | 18 | build() { 19 | cd "$_name" 20 | export CGO_CPPFLAGS="${CPPFLAGS}" 21 | export CGO_CFLAGS="${CFLAGS}" 22 | export CGO_CXXFLAGS="${CXXFLAGS}" 23 | export CGO_LDFLAGS="${LDFLAGS}" 24 | export GOFLAGS="-buildmode=pie -trimpath -ldflags=-linkmode=external -mod=readonly -modcacherw" 25 | go build -o sku ./cmd/sku/main.go 26 | } 27 | 28 | package() { 29 | cd "$_name" 30 | install -Dm755 sku "$pkgdir/usr/bin/sku" 31 | install -Dm644 license "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/version.yaml: -------------------------------------------------------------------------------- 1 | name: Update package version 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | 7 | jobs: 8 | update-version: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.18 20 | 21 | - name: Generate version 22 | run: go generate ./... 23 | 24 | - name: Update AUR PKGBUILD 25 | run: | 26 | version=$(cat ./cmd/sku/.version) 27 | sed -i "s|^pkgver=.*|pkgver=$version|" ./template/archlinux/PKGBUILD 28 | 29 | - name: Commit and push changes 30 | uses: devops-infra/action-commit-push@v0.9.0 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | amend: true 34 | no_edit: true 35 | force: true 36 | -------------------------------------------------------------------------------- /pkg/sudoku/sudoku_test.go: -------------------------------------------------------------------------------- 1 | package sudoku 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestSudoku(t *testing.T) { 9 | t.Log("TestSudoku") 10 | var sudoku Sudoku 11 | modes := map[int]string{ 12 | EASY: "easy", 13 | MEDIUM: "medium", 14 | HARD: "hard", 15 | EXPERT: "expert", 16 | } 17 | 18 | for d, mode := range modes { 19 | t.Log("Mode:", mode) 20 | sudoku = *New(d) 21 | t.Log(printSudoku(&sudoku)) 22 | } 23 | } 24 | 25 | func printSudoku(s *Sudoku) string { 26 | board := "\n" 27 | for i := 0; i < 81; i = i + 9 { 28 | board += fmt.Sprintf("%d\n", s.Puzzle[i:i+9]) 29 | } 30 | board += "\n" 31 | for i := 0; i < 81; i = i + 9 { 32 | board += fmt.Sprintf("%d\n", s.Answer[i:i+9]) 33 | } 34 | notErased := 0 35 | for i := 0; i < 81; i++ { 36 | if s.Puzzle[i] == 0 { 37 | board += "." 38 | } else { 39 | board += fmt.Sprintf("%d", s.Puzzle[i]) 40 | notErased++ 41 | } 42 | } 43 | board += fmt.Sprintf("\nNot erased: %d", notErased) 44 | return board 45 | } 46 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 fedeztk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy docker image 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Update package version"] 6 | branches: ["master"] 7 | types: ["completed"] 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: ${{ github.repository }} 12 | ACTOR: ${{ github.actor }} 13 | 14 | jobs: 15 | build-and-push-image: 16 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v3 25 | 26 | - name: Log in to the Container registry 27 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ env.ACTOR }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Build and push Docker image 34 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 35 | with: 36 | context: . 37 | push: true 38 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 39 | -------------------------------------------------------------------------------- /pkg/sudoku/README.md: -------------------------------------------------------------------------------- 1 | # Sudoku Engine 2 | This package is a **non** efficient implementation of a sudoku solver and generator. It is ~~stolen~~ ported from [this python implementation](https://www.101computing.net/sudoku-generator-algorithm/), all credits go to the author. It is a simple recursive and backtracking engine, easy to understand and read. 3 | 4 | ## Motivation 5 | This minimal implementation was done due to the lack of a package that simply has the two sensible features needed by a sudoku engine: 6 | 1. can both solve & generate 7 | 2. do generate a one-solution puzzle 8 | 9 | without useless features or generally cumbersome development experience (e.g. overly complicated structures, interfaces, initialization methods, etc). 10 | 11 | If you need the aforementioned features this package is not the right one, since it only does point 1 and 2. 12 | 13 | If you need a super efficient implementation this package is not the right one since it is aimed to be simple at the expense of speed; if you need something like that you can use/write one that leverages a more performant algorithm such as dancing links (dlx). 14 | 15 | ## Usage 16 | ```go 17 | sudoku := sudoku.New(sudoku.EASY) // also available: MEDIUM, HARD, EXPERT 18 | 19 | fmt.Println(sudoku.Puzzle) // unsolved sudoku [81]int 20 | fmt.Println(sudoku.Answer) // solved sudoku [81]int 21 | ``` -------------------------------------------------------------------------------- /cmd/sku/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | model "github.com/fedeztk/sku/internal/model" 10 | "github.com/fedeztk/sku/pkg/sudoku" 11 | ) 12 | 13 | const ( 14 | LEVEL_EASY = iota 15 | LEVEL_MEDIUM 16 | LEVEL_HARD 17 | LEVEL_EXPERT 18 | ) 19 | 20 | //go:generate ./get_version.sh 21 | //go:embed .version 22 | var skuVersion string 23 | 24 | func main() { 25 | var mode int 26 | modesMap := map[string]int{ 27 | "easy": sudoku.EASY, 28 | "medium": sudoku.MEDIUM, 29 | "hard": sudoku.HARD, 30 | "expert": sudoku.EXPERT, 31 | } 32 | 33 | if len(os.Args) < 2 { 34 | mode = sudoku.EASY 35 | } else { 36 | if os.Args[1] == "-v" || os.Args[1] == "--version" { 37 | fmt.Println(skuVersion) 38 | os.Exit(0) 39 | } 40 | 41 | if os.Args[1] == "-h" || os.Args[1] == "--help" { 42 | fmt.Println(getHelp()) 43 | os.Exit(0) 44 | } 45 | 46 | if _, ok := modesMap[os.Args[1]]; !ok { 47 | fmt.Printf("Invalid mode: %s\n", os.Args[1]) 48 | fmt.Println(getHelp()) 49 | os.Exit(1) 50 | } 51 | 52 | mode = modesMap[os.Args[1]] 53 | } 54 | 55 | p := tea.NewProgram(model.NewModel(mode), tea.WithAltScreen(), tea.WithMouseCellMotion()) 56 | if err := p.Start(); err != nil { 57 | fmt.Fprintln(os.Stderr, err) 58 | os.Exit(1) 59 | } 60 | } 61 | 62 | func getHelp() string { 63 | return `sku - a simple sudoku game 64 | -v, --version show version 65 | -h, --help show this help 66 | [mode] easy, medium, hard, expert` 67 | } 68 | -------------------------------------------------------------------------------- /internal/model/components/keys/keys.go: -------------------------------------------------------------------------------- 1 | package keys 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | ) 6 | 7 | type KeyMap struct { 8 | Up key.Binding 9 | Down key.Binding 10 | Left key.Binding 11 | Right key.Binding 12 | Help key.Binding 13 | Quit key.Binding 14 | Clear key.Binding 15 | Number key.Binding 16 | } 17 | 18 | func (k KeyMap) ShortHelp() []key.Binding { 19 | return []key.Binding{k.Help, k.Quit} 20 | } 21 | 22 | func (k KeyMap) FullHelp() [][]key.Binding { 23 | return [][]key.Binding{ 24 | {k.Up, k.Down}, 25 | {k.Left, k.Right}, 26 | {k.Help, k.Quit}, 27 | {k.Number, k.Clear}, 28 | } 29 | } 30 | 31 | var Keys = KeyMap{ 32 | Up: key.NewBinding( 33 | key.WithKeys("up", "k"), 34 | key.WithHelp("↑/k", "move up"), 35 | ), 36 | Down: key.NewBinding( 37 | key.WithKeys("down", "j"), 38 | key.WithHelp("↓/j", "move down"), 39 | ), 40 | Left: key.NewBinding( 41 | key.WithKeys("left", "h"), 42 | key.WithHelp("←/h", "move left"), 43 | ), 44 | Right: key.NewBinding( 45 | key.WithKeys("right", "l"), 46 | key.WithHelp("→/l", "move right"), 47 | ), 48 | Help: key.NewBinding( 49 | key.WithKeys("?"), 50 | key.WithHelp("?", "toggle help"), 51 | ), 52 | Quit: key.NewBinding( 53 | key.WithKeys("q", "esc"), 54 | key.WithHelp("q/esc", "quit"), 55 | ), 56 | Clear: key.NewBinding( 57 | key.WithKeys("enter", " "), 58 | key.WithHelp("↵/space", "clear cell"), 59 | ), 60 | Number: key.NewBinding( 61 | key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8", "9"), 62 | key.WithHelp("", "set cell to number"), 63 | ), 64 | } 65 | -------------------------------------------------------------------------------- /internal/model/components/board/style.go: -------------------------------------------------------------------------------- 1 | package board 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | var ( 10 | cellStyle = func(modifiable bool) lipgloss.Style { 11 | if modifiable { 12 | return lipgloss.NewStyle().PaddingLeft(1).Background(lipgloss.Color("7")). 13 | PaddingRight(1).Foreground(lipgloss.Color("0")) 14 | } else { 15 | return lipgloss.NewStyle().PaddingLeft(1).Background(lipgloss.Color("8")). 16 | PaddingRight(1) 17 | } 18 | } 19 | 20 | cursorCellStyle = func(modifiable bool) lipgloss.Style { 21 | if modifiable { 22 | return lipgloss.NewStyle().PaddingLeft(1).Background(lipgloss.Color("10")). 23 | PaddingRight(1) 24 | } else { 25 | return lipgloss.NewStyle().PaddingLeft(1).Background(lipgloss.Color("2")). 26 | PaddingRight(1) 27 | } 28 | } 29 | 30 | errorCellStyle = func(isCursor bool) lipgloss.Style { 31 | if isCursor { 32 | return lipgloss.NewStyle().PaddingLeft(1).Background(lipgloss.Color("9")). 33 | PaddingRight(1) 34 | } else { 35 | return lipgloss.NewStyle().PaddingLeft(1).Background(lipgloss.Color("1")). 36 | PaddingRight(1) 37 | } 38 | } 39 | 40 | formatCell = func(isError, isCursor, modifiable bool, row, col int, c string) string { 41 | var s lipgloss.Style 42 | if isError { 43 | s = errorCellStyle(isCursor) 44 | } else if isCursor { 45 | s = cursorCellStyle(modifiable) 46 | } else { 47 | s = cellStyle(modifiable) 48 | } 49 | 50 | // ugly hack to get the border at the center with 1 char margin 51 | if col+1 == 3 || col+1 == 6 { 52 | return s.Render(c) + lipgloss.NewStyle(). 53 | Border(lipgloss.NormalBorder(), false, true, false, false).Margin(0, 1).Render("") 54 | } 55 | 56 | return s.Render(c) 57 | } 58 | 59 | formatRow = func(row int, r string) string { 60 | // ugly hack to get the border at the center with 1 char margin 61 | if row+1 == 3 || row+1 == 6 { 62 | rSize, _ := lipgloss.Size(r) 63 | // there are 3 sudoku boxes hence /3, the -1 is to leave space for the crossing border 64 | border := strings.Repeat("─", (rSize/3)-1) 65 | 66 | // the extra "─" is required to center the middle box 67 | return r + "\n" + border + "┼" + "─" + border + "┼" + border 68 | } 69 | return r 70 | } 71 | 72 | cellsLeftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Margin(1, 0, 0, 0) 73 | ) 74 | -------------------------------------------------------------------------------- /internal/model/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/fedeztk/sku/internal/model/components/animate" 8 | "github.com/fedeztk/sku/internal/model/components/board" 9 | "github.com/fedeztk/sku/internal/model/components/help" 10 | "github.com/fedeztk/sku/internal/model/components/keys" 11 | "github.com/fedeztk/sku/internal/model/components/stopwatch" 12 | ) 13 | 14 | type Model struct { 15 | help help.Model 16 | stopwatch stopwatch.Model 17 | board board.Model 18 | animate animate.Model 19 | 20 | width, height int 21 | gameEnded bool 22 | 23 | err error 24 | } 25 | 26 | func (m Model) Init() tea.Cmd { 27 | return m.stopwatch.Init() 28 | } 29 | 30 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 31 | var cmds []tea.Cmd 32 | var cmd, helpCmd, boardCmd, stopwatchCmd, animCmd tea.Cmd 33 | 34 | switch msg := msg.(type) { 35 | case tea.KeyMsg: 36 | switch { 37 | case key.Matches(msg, keys.Keys.Quit): 38 | cmd = tea.Quit 39 | } 40 | 41 | case tea.WindowSizeMsg: 42 | m.width = msg.Width 43 | m.height = msg.Height 44 | 45 | case board.GameWon: 46 | m.gameEnded = true 47 | cmd = m.animate.Init() 48 | } 49 | 50 | m.help, helpCmd = m.help.Update(msg) 51 | m.board, boardCmd = m.board.Update(msg) 52 | m.stopwatch, stopwatchCmd = m.stopwatch.Update(msg) 53 | m.animate, animCmd = m.animate.Update(msg) 54 | 55 | cmds = append(cmds, cmd, helpCmd, boardCmd, stopwatchCmd, animCmd) 56 | 57 | return m, tea.Batch(cmds...) 58 | } 59 | 60 | func (m Model) View() string { 61 | if m.err != nil { 62 | return m.err.Error() 63 | } 64 | 65 | if m.gameEnded { 66 | return lipgloss.Place(m.width-int(m.animate.TargetX), 67 | m.height, lipgloss.Center, lipgloss.Center, m.animate.View()) 68 | } 69 | 70 | // spaces on board and help view are needed to make the view centered 71 | mainView := lipgloss.JoinVertical(lipgloss.Center, 72 | m.board.View()+" ", m.stopwatch.View(), " "+m.help.View()) 73 | 74 | mainView = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, mainView) 75 | 76 | return mainView 77 | } 78 | 79 | func NewModel(mode int) Model { 80 | return Model{ 81 | help: help.NewModel(), 82 | stopwatch: stopwatch.NewModel(), 83 | board: board.NewModel(mode), 84 | animate: animate.NewModel(), 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /internal/model/components/animate/animate.go: -------------------------------------------------------------------------------- 1 | package animate 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "time" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/harmonica" 11 | ) 12 | 13 | const ( 14 | fps = 60 15 | spriteWidth = 12 16 | spriteHeight = 5 17 | frequency = 5.0 18 | damping = 0.15 19 | ) 20 | 21 | type frameMsg time.Time 22 | 23 | type Model struct { 24 | x float64 25 | xVel float64 26 | TargetX float64 27 | spring harmonica.Spring 28 | } 29 | 30 | func animate() tea.Cmd { 31 | return tea.Tick(time.Second/fps, func(t time.Time) tea.Msg { 32 | return frameMsg(t) 33 | }) 34 | } 35 | 36 | func wait(d time.Duration) tea.Cmd { 37 | return func() tea.Msg { 38 | time.Sleep(d) 39 | return nil 40 | } 41 | } 42 | 43 | func NewModel() Model { 44 | return Model{ 45 | spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping), 46 | TargetX: 20, 47 | } 48 | } 49 | 50 | func (m Model) Init() tea.Cmd { 51 | return tea.Sequentially(wait(time.Second/2), animate()) 52 | } 53 | 54 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 55 | switch msg.(type) { 56 | 57 | // Step foreward one frame 58 | case frameMsg: 59 | // Update x position (and velocity) with our spring. 60 | m.x, m.xVel = m.spring.Update(m.x, m.xVel, m.TargetX) 61 | 62 | // Quit when we're basically at the target position. 63 | if math.Abs(m.x-m.TargetX) < 0.01 { 64 | return m, tea.Sequentially(wait(3/4*time.Second), tea.Quit) 65 | } 66 | 67 | // Request next frame 68 | return m, animate() 69 | 70 | default: 71 | return m, nil 72 | } 73 | } 74 | 75 | func (m Model) View() string { 76 | var out strings.Builder 77 | fmt.Fprint(&out, "\n") 78 | 79 | x := int(math.Round(m.x)) 80 | if x < 0 { 81 | return "" 82 | } 83 | 84 | spriteRow := spriteStyle.Render(strings.Repeat(" ", spriteWidth) + 85 | getWinningString(int(m.x)) + 86 | strings.Repeat(" ", spriteWidth)) 87 | 88 | row := strings.Repeat(" ", x) + spriteRow + "\n" 89 | 90 | fmt.Fprint(&out, strings.Repeat(row, spriteHeight)) 91 | 92 | return out.String() 93 | } 94 | 95 | func getWinningString(timeFrame int) string { 96 | won := "YOU WON!" 97 | res := "" 98 | 99 | for i, c := range won { 100 | if i < timeFrame { 101 | res += string(c) 102 | } else { 103 | res += " " 104 | } 105 | } 106 | 107 | return res 108 | } 109 | -------------------------------------------------------------------------------- /pkg/sudoku/sudoku.go: -------------------------------------------------------------------------------- 1 | package sudoku 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | const ( 9 | // available sudoku difficulties 10 | EASY = 42 11 | MEDIUM = 36 12 | HARD = 27 13 | EXPERT = 25 14 | // time constraints 15 | MAX_ATTEMPTS = 5 16 | MAX_EXEC_TIME = 3 17 | // sudoku board size 18 | SUDOKU_LENGTH = 9 19 | SUDOKU_SIZE = SUDOKU_LENGTH * SUDOKU_LENGTH 20 | ) 21 | 22 | type Sudoku struct { 23 | Puzzle [SUDOKU_SIZE]int 24 | Answer [SUDOKU_SIZE]int 25 | } 26 | 27 | func New(difficulty int) *Sudoku { 28 | for i := 0; i < MAX_ATTEMPTS; i++ { 29 | if s, ok := newWithTimer(difficulty); ok { 30 | return s 31 | } 32 | } 33 | return nil 34 | } 35 | 36 | func newWithTimer(difficulty int) (*Sudoku, bool) { 37 | s := &Sudoku{} 38 | 39 | done := make(chan struct{}) 40 | var ok bool 41 | 42 | go func() { 43 | s = &Sudoku{} 44 | fill(&s.Puzzle) 45 | s.Answer = s.Puzzle 46 | s.eraseSome(difficulty) 47 | close(done) 48 | }() 49 | 50 | select { 51 | case <-done: 52 | ok = true 53 | case <-time.After(time.Second * MAX_EXEC_TIME): 54 | } 55 | 56 | return s, ok 57 | } 58 | 59 | func (s *Sudoku) eraseSome(difficulty int) { 60 | for erased := 0; SUDOKU_SIZE-erased > difficulty; { 61 | idx := 0 62 | for s.Puzzle[idx] == 0 { 63 | rand.Seed(time.Now().UnixNano()) 64 | idx = rand.Intn(SUDOKU_SIZE) 65 | } 66 | 67 | copyGrid := s.Puzzle 68 | copyGrid[idx] = 0 69 | 70 | count := 0 71 | solve(©Grid, &count) 72 | if count == 1 { 73 | s.Puzzle[idx] = 0 74 | erased++ 75 | } 76 | } 77 | } 78 | 79 | func solve(grid *[SUDOKU_SIZE]int, count *int) { 80 | if *count > 1 { // no need to go further 81 | return 82 | } 83 | 84 | for idx := 0; idx < SUDOKU_SIZE; idx++ { 85 | if grid[idx] == 0 { 86 | for n := 1; n <= SUDOKU_LENGTH; n++ { 87 | if isValid(grid, idx, n) { 88 | grid[idx] = n 89 | if checkFull(grid) { 90 | *count++ 91 | } 92 | solve(grid, count) 93 | grid[idx] = 0 94 | } 95 | } 96 | break 97 | } 98 | } 99 | } 100 | 101 | func fill(grid *[SUDOKU_SIZE]int) bool { 102 | numberList := [SUDOKU_LENGTH]int{1, 2, 3, 4, 5, 6, 7, 8, 9} 103 | 104 | for idx := 0; idx < SUDOKU_SIZE; idx++ { 105 | if grid[idx] == 0 { 106 | rand.Seed(time.Now().UnixNano()) 107 | rand.Shuffle(len(numberList), func(i, j int) { 108 | numberList[i], numberList[j] = numberList[j], numberList[i] 109 | }) 110 | 111 | for _, n := range numberList { 112 | if isValid(grid, idx, n) { 113 | grid[idx] = n 114 | if checkFull(grid) || fill(grid) { 115 | return true 116 | } 117 | grid[idx] = 0 118 | } 119 | } 120 | break 121 | } 122 | } 123 | return false 124 | } 125 | 126 | func checkFull(grid *[SUDOKU_SIZE]int) bool { 127 | for i := 0; i < SUDOKU_SIZE; i++ { 128 | if grid[i] == 0 { 129 | return false 130 | } 131 | } 132 | return true 133 | } 134 | 135 | func isValid(grid *[SUDOKU_SIZE]int, idx, n int) bool { 136 | // check if num is valid in row, col, and 3x3 box 137 | row := idx / SUDOKU_LENGTH 138 | col := idx % SUDOKU_LENGTH 139 | for i := 0; i < SUDOKU_LENGTH; i++ { 140 | if grid[row*SUDOKU_LENGTH+i] == n || 141 | grid[i*SUDOKU_LENGTH+col] == n || 142 | grid[((row/3)*3+i/3)*SUDOKU_LENGTH+((col/3)*3+i%3)] == n { 143 | return false 144 | } 145 | } 146 | return true 147 | } 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sku 2 | Simple TUI written in go to play sudoku in the terminal 3 | 4 | [![GO](https://github.com/fedeztk/sku/actions/workflows/go.yaml/badge.svg)](https://github.com/fedeztk/sku/tree/master/.github/workflows/go.yml) [![GHCR](https://github.com/fedeztk/sku/actions/workflows/deploy.yaml/badge.svg)](https://github.com/fedeztk/sku/tree/release/.github/workflows/deploy.yml) [![AUR](https://img.shields.io/aur/version/sku-git?logo=archlinux)](https://aur.archlinux.org/packages/sku-git) [![Go Report Card](https://goreportcard.com/badge/github.com/fedeztk/sku)](https://goreportcard.com/report/github.com/fedeztk/sku) 5 | 6 | ## Table of Contents 7 | 8 | [Usage](#orgfa2aa9c) - 9 | [Features](#org26baa6c) - 10 | [Testing](#org2744438) 11 | 12 | sku is a simple TUI for playing sudoku inside the terminal. It uses the awesome [bubbletea](https://github.com/charmbracelet/bubbletea) TUI library for the UI. 13 | 14 | Screenshots [here](#org26baa6c) 15 | 16 | > Disclaimer: there are probably many other sudoku TUIs around with all kind of features that sku does not have. PRs are welcomed but it is generally a better idea to just use those programs, since adding some features, like a pencil mode used to annotate the sudoku, would require too much effort. 17 | 18 | 19 | 20 | 21 | # Usage 22 | 23 | - Install `sku`: 24 | 25 | With the `go` tool: 26 | ```sh 27 | go install github.com/fedeztk/sku/cmd/sku@latest 28 | ``` 29 | **Or** from source: 30 | ```sh 31 | # clone the repo 32 | git clone https://github.com/fedeztk/sku.git 33 | # install manually 34 | make install 35 | ``` 36 | In both cases make sure that you have the go `bin` directory in your path: 37 | ```sh 38 | export PATH="$HOME/go/bin:$PATH" 39 | ``` 40 | If you are an Arch user there is also an AUR package available: 41 | ```sh 42 | paru -S sku-git 43 | ``` 44 | - Run it interactively: 45 | ```sh 46 | sku # use default mode (easy) 47 | ``` 48 | For more information about version and modes check the help (`sku -h`) 49 | 50 | 51 | # Features 52 | 53 | - Minimal/clean interface, only displays the board, the help and the game state (timer and remaining cells). Cursor position is marked with green. Greyed cells are unmodifiable (the base of the sudoku); when the cursor is under those cells it will darken 54 | ![Screenshot from 2022-06-06 17-35-14](https://user-images.githubusercontent.com/58485208/172200830-677fb8f4-ea29-455c-989b-1c5ea774ae78.png) 55 | 56 | - Game check: 57 | - when the last cell is filled, `sku` will perform a check of the sudoku: 58 | - if it is correct, an animation will let you know that you won the game 59 | 60 | https://user-images.githubusercontent.com/58485208/172200661-78ce055f-b5b9-44aa-bf4d-a27e9f8fce85.mp4 61 | 62 | - otherwise it will color with red the errors 63 | ![Screenshot from 2022-06-06 17-36-56](https://user-images.githubusercontent.com/58485208/172201574-e1ebe9ec-fc44-4d6c-a80a-287c8433133d.png) 64 | 65 | 66 | - Simple keys to interact with the puzzle: 67 | - moving around: use the arrows or the vim motion keys, as preferred 68 | - setting a cell: just press the desired number 69 | - unsetting a cell: press spacebar or enter 70 | - toggle help: press the question mark 71 | - quit: you can quit anytime by pressing esc or q 72 | ![Screenshot from 2022-06-06 17-36-19](https://user-images.githubusercontent.com/58485208/172201555-1dcc1851-6853-4760-ac7a-6d5285c2f0b6.png) 73 | 74 | 75 | - Check the version with `sku -v` 76 | - Get the help menu with `sku -h` 77 | - Set a sudoku mode with `sku MODE`. Valid MODEs are: easy, medium, hard and expert (also displayed with `-h` flag) 78 | 79 | 80 | 81 | 82 | # Testing 83 | 84 | Development is done through `docker`, build the container with: 85 | 86 | make docker-build 87 | 88 | Check that the build went fine: 89 | 90 | docker images | grep sku 91 | 92 | Test it with: 93 | 94 | make docker-run 95 | 96 | Pre-built Docker image available [here](https://github.com/fedeztk/sku/pkgs/container/sku) 97 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 2 | github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= 3 | github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= 4 | github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= 5 | github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= 6 | github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= 7 | github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0= 8 | github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 9 | github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= 10 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 11 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 12 | github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= 13 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 14 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 15 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 16 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 17 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 18 | github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 19 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 20 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 21 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 22 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 23 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 24 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 25 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 26 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= 27 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 28 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 29 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 30 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 31 | github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= 32 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 33 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 34 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 35 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 36 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 38 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 39 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 40 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 41 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= 46 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 48 | golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= 49 | golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 50 | -------------------------------------------------------------------------------- /internal/model/components/board/board.go: -------------------------------------------------------------------------------- 1 | package board 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/fedeztk/sku/internal/model/components/keys" 9 | "github.com/fedeztk/sku/pkg/sudoku" 10 | ) 11 | 12 | type Model struct { 13 | board [sudoku_len][sudoku_len]struct { 14 | puzzle int 15 | answer int 16 | modifiable bool 17 | } 18 | KeyMap keys.KeyMap 19 | cursor coordinate 20 | cellsLeft int 21 | errCoordinates map[coordinate]interface{} 22 | 23 | Err error 24 | } 25 | 26 | type GameWon struct{} 27 | 28 | type gameCheck struct { 29 | Err error 30 | result map[coordinate]interface{} // maybe not the best way to do this 31 | } 32 | 33 | type coordinate struct { 34 | row, col int 35 | } 36 | 37 | const ( 38 | sudoku_len = 9 39 | ) 40 | 41 | func NewModel(mode int) Model { 42 | var cellsLeft int 43 | var board [sudoku_len][sudoku_len]struct { 44 | puzzle int 45 | answer int 46 | modifiable bool 47 | } 48 | 49 | sudoku := sudoku.New(mode) 50 | puzzle, answer := sudoku.Puzzle, sudoku.Answer 51 | 52 | for i := 0; i < sudoku_len; i++ { 53 | for j := 0; j < sudoku_len; j++ { 54 | board[i][j].puzzle = puzzle[i*sudoku_len+j] 55 | board[i][j].answer = answer[i*sudoku_len+j] 56 | if modifiable := puzzle[i*sudoku_len+j] == 0; modifiable { 57 | board[i][j].modifiable = modifiable 58 | cellsLeft++ 59 | } 60 | } 61 | } 62 | 63 | return Model{ 64 | board: board, 65 | KeyMap: keys.Keys, 66 | cellsLeft: cellsLeft, 67 | errCoordinates: make(map[coordinate]interface{}), 68 | } 69 | } 70 | 71 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 72 | switch msg := msg.(type) { 73 | case tea.KeyMsg: 74 | switch { 75 | case key.Matches(msg, m.KeyMap.Down): 76 | m.cursorDown() 77 | case key.Matches(msg, m.KeyMap.Up): 78 | m.cursorUp() 79 | case key.Matches(msg, m.KeyMap.Left): 80 | m.cursorLeft() 81 | case key.Matches(msg, m.KeyMap.Right): 82 | m.cursorRight() 83 | case key.Matches(msg, m.KeyMap.Clear): 84 | m.clear(m.cursor.row, m.cursor.col) 85 | case key.Matches(msg, m.KeyMap.Number): 86 | return m, m.set(m.cursor.row, m.cursor.col, int(msg.String()[0]-'0')) 87 | } 88 | case gameCheck: 89 | m.Err = msg.Err 90 | m.errCoordinates = msg.result 91 | if msg.Err == nil { 92 | return m, m.won() 93 | } 94 | } 95 | return m, nil 96 | } 97 | 98 | func (m Model) Init() tea.Cmd { 99 | return nil 100 | } 101 | 102 | func (m Model) View() string { 103 | // replace 0 with empty string 104 | var maybeReplace = func(v int) string { 105 | if v == 0 { 106 | return " " 107 | } 108 | return fmt.Sprintf("%d", v) 109 | } 110 | 111 | var boardView string 112 | for i := 0; i < sudoku_len; i++ { 113 | row := "" 114 | for j := 0; j < sudoku_len; j++ { 115 | _, isError := m.errCoordinates[coordinate{i, j}] 116 | 117 | row += formatCell(isError, m.cursor.row == i && m.cursor.col == j, 118 | m.board[i][j].modifiable, i, j, maybeReplace(m.board[i][j].puzzle)) 119 | } 120 | boardView += formatRow(i, row) + "\n" 121 | } 122 | 123 | return boardView + cellsLeftStyle.Render(fmt.Sprintf("Cells left: %d", m.cellsLeft)) 124 | } 125 | 126 | func (m *Model) set(row, col int, v int) tea.Cmd { 127 | if m.board[row][col].modifiable { 128 | if m.board[row][col].puzzle == 0 { 129 | m.cellsLeft-- 130 | } 131 | m.board[row][col].puzzle = v 132 | 133 | delete(m.errCoordinates, coordinate{row, col}) 134 | 135 | if m.cellsLeft == 0 { 136 | return m.check() 137 | } 138 | } 139 | return nil 140 | } 141 | 142 | func (m *Model) clear(row, col int) { 143 | if m.board[row][col].modifiable { 144 | if m.board[row][col].puzzle != 0 { 145 | m.cellsLeft++ 146 | } 147 | m.board[row][col].puzzle = 0 148 | 149 | delete(m.errCoordinates, coordinate{row, col}) 150 | } 151 | } 152 | 153 | func (m *Model) cursorDown() { 154 | if m.cursor.row < sudoku_len-1 { 155 | m.cursor.row++ 156 | } else { 157 | m.cursor.row = 0 158 | } 159 | } 160 | 161 | func (m *Model) cursorUp() { 162 | if m.cursor.row > 0 { 163 | m.cursor.row-- 164 | } else { 165 | m.cursor.row = sudoku_len - 1 166 | } 167 | } 168 | 169 | func (m *Model) cursorLeft() { 170 | if m.cursor.col > 0 { 171 | m.cursor.col-- 172 | } else { 173 | m.cursor.col = sudoku_len - 1 174 | } 175 | } 176 | 177 | func (m *Model) cursorRight() { 178 | if m.cursor.col < sudoku_len-1 { 179 | m.cursor.col++ 180 | } else { 181 | m.cursor.col = 0 182 | } 183 | } 184 | 185 | func (m *Model) check() tea.Cmd { 186 | return func() tea.Msg { 187 | result := make(map[coordinate]interface{}) 188 | for i := 0; i < sudoku_len; i++ { 189 | for j := 0; j < sudoku_len; j++ { 190 | if m.board[i][j].puzzle != m.board[i][j].answer { 191 | result[coordinate{i, j}] = nil 192 | } 193 | } 194 | } 195 | 196 | if len(result) == 0 { 197 | return gameCheck{Err: nil, result: result} 198 | } 199 | return gameCheck{Err: fmt.Errorf("%d errors", len(result)), result: result} 200 | } 201 | } 202 | 203 | func (m *Model) won() tea.Cmd { 204 | return func() tea.Msg { 205 | return GameWon{} 206 | } 207 | } 208 | --------------------------------------------------------------------------------