├── .deepsource.toml
├── .github
└── workflows
│ ├── docker-push.yml
│ ├── golangci-lint.yml
│ └── goreleaser.yml
├── .gitignore
├── .goreleaser.yaml
├── Dockerfile
├── LICENSE
├── README.md
├── dictionary.go
├── go.mod
├── go.sum
├── main.go
├── model.go
├── preview.png
├── query.sql
├── schema.sql
├── sqlc.yaml
└── store
├── db.go
├── models.go
└── query.sql.go
/.deepsource.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | [[analyzers]]
4 | name = "go"
5 | enabled = true
6 |
7 | [analyzers.meta]
8 | import_root = "github.com/ajeetdsouza/clidle"
--------------------------------------------------------------------------------
/.github/workflows/docker-push.yml:
--------------------------------------------------------------------------------
1 | name: docker-push
2 | on:
3 | push:
4 | branches:
5 | - main
6 | workflow_dispatch:
7 | jobs:
8 | docker:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v4
13 | - name: Login to Docker Hub
14 | uses: docker/login-action@v3
15 | with:
16 | username: ${{ vars.DOCKERHUB_USERNAME }}
17 | password: ${{ secrets.DOCKERHUB_TOKEN }}
18 | - name: Set up QEMU
19 | uses: docker/setup-qemu-action@v3
20 | - name: Set up Docker Buildx
21 | uses: docker/setup-buildx-action@v3
22 | - name: Build and push
23 | uses: docker/build-push-action@v6
24 | with:
25 | context: .
26 | platforms: linux/amd64,linux/arm64
27 | push: true
28 | tags: ajeetdsouza/clidle:latest
29 |
--------------------------------------------------------------------------------
/.github/workflows/golangci-lint.yml:
--------------------------------------------------------------------------------
1 | name: golangci-lint
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | workflow_dispatch:
8 | permissions:
9 | contents: read
10 | jobs:
11 | golangci:
12 | name: lint
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: golangci-lint
17 | uses: golangci/golangci-lint-action@v2
18 |
--------------------------------------------------------------------------------
/.github/workflows/goreleaser.yml:
--------------------------------------------------------------------------------
1 | name: goreleaser
2 | on:
3 | push:
4 | tags:
5 | - "v*"
6 | permissions:
7 | contents: write
8 | jobs:
9 | goreleaser:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v4
14 | with:
15 | fetch-depth: 0
16 | - name: Set up Go
17 | uses: actions/setup-go@v5
18 | - name: Run GoReleaser
19 | uses: goreleaser/goreleaser-action@v6
20 | with:
21 | # either 'goreleaser' (default) or 'goreleaser-pro'
22 | distribution: goreleaser
23 | # 'latest', 'nightly', or a semver
24 | version: "~> v2"
25 | args: release --clean
26 | env:
27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution
29 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
30 |
--------------------------------------------------------------------------------
/.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 | # Go workspace file
15 | go.work
16 |
17 | ### Go Patch ###
18 | /vendor/
19 | /Godeps/
20 |
21 | # GoReleaser
22 | dist/
23 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | # This is an example .goreleaser.yml file with some sensible defaults.
2 | # Make sure to check the documentation at https://goreleaser.com
3 |
4 | # The lines below are called `modelines`. See `:help modeline`
5 | # Feel free to remove those if you don't want/need to use them.
6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj
8 |
9 | version: 2
10 |
11 | before:
12 | hooks:
13 | # You may remove this if you don't use go modules.
14 | - go mod tidy
15 | # you may remove this if you don't need go generate
16 | - go generate ./...
17 |
18 | builds:
19 | - env:
20 | - CGO_ENABLED=0
21 | goos:
22 | - linux
23 | - windows
24 | - darwin
25 |
26 | archives:
27 | - format: tar.gz
28 | # this name template makes the OS and Arch compatible with the results of `uname`.
29 | name_template: >-
30 | {{ .ProjectName }}_
31 | {{- title .Os }}_
32 | {{- if eq .Arch "amd64" }}x86_64
33 | {{- else if eq .Arch "386" }}i386
34 | {{- else }}{{ .Arch }}{{ end }}
35 | {{- if .Arm }}v{{ .Arm }}{{ end }}
36 | # use zip for windows archives
37 | format_overrides:
38 | - goos: windows
39 | format: zip
40 |
41 | changelog:
42 | sort: asc
43 | filters:
44 | exclude:
45 | - "^docs:"
46 | - "^test:"
47 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.23.4-alpine
2 |
3 | WORKDIR /app
4 |
5 | COPY go.mod .
6 | COPY go.sum .
7 | RUN go mod download
8 |
9 | COPY *.go .
10 | COPY store/*.go store/
11 | COPY schema.sql .
12 | RUN go build -o clidle .
13 |
14 | FROM scratch
15 |
16 | COPY --from=0 /app/clidle .
17 |
18 | ENV CLICOLOR_FORCE=1
19 | ENV CLIDLE_DATA_DIR=/opt/clidle/data
20 | CMD ["./clidle", "-serve", "0.0.0.0:22"]
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Ajeet D'Souza
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # clidle
4 |
5 | **Wordle, now over SSH.**
6 |
7 |

8 |
9 | **Try it:**
10 |
11 | ```sh
12 | ssh clidle.duckdns.org -p 3000
13 | ```
14 |
15 | **Or, run it locally:**
16 |
17 | ```sh
18 | go install github.com/ajeetdsouza/clidle@latest
19 | ```
20 |
21 |
22 |
23 | ## How to play
24 |
25 | You have 6 attempts to guess the correct word. Each guess must be a valid 5 letter
26 | word.
27 |
28 | After submitting a guess, the letters will turn green, yellow, or gray.
29 |
30 | - **Green:** The letter is correct, and is in the correct position.
31 | - **Yellow:** The letter is present in the solution, but is in the wrong position.
32 | - **Gray:** The letter is not present in the solution.
33 |
34 | ## Scoring
35 |
36 | Your final score is based on how many guesses it took to arrive at the solution:
37 |
38 | | Guesses | Score |
39 | | ------- | ----- |
40 | | 1 | 100 |
41 | | 2 | 90 |
42 | | 3 | 80 |
43 | | 4 | 70 |
44 | | 5 | 60 |
45 | | 6 | 50 |
46 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ajeetdsouza/clidle
2 |
3 | go 1.22.0
4 |
5 | toolchain go1.23.2
6 |
7 | require (
8 | github.com/adrg/xdg v0.5.2
9 | github.com/charmbracelet/bubbletea v1.1.2
10 | github.com/charmbracelet/lipgloss v0.13.1
11 | github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa
12 | github.com/charmbracelet/wish v1.4.3
13 | github.com/pkg/errors v0.9.1
14 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678
15 | modernc.org/sqlite v1.33.1
16 | )
17 |
18 | require (
19 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
20 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
21 | github.com/charmbracelet/keygen v0.5.1 // indirect
22 | github.com/charmbracelet/log v0.4.0 // indirect
23 | github.com/charmbracelet/x/ansi v0.4.2 // indirect
24 | github.com/charmbracelet/x/conpty v0.1.0 // indirect
25 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
26 | github.com/charmbracelet/x/input v0.2.0 // indirect
27 | github.com/charmbracelet/x/term v0.2.0 // indirect
28 | github.com/charmbracelet/x/termios v0.1.0 // indirect
29 | github.com/creack/pty v1.1.21 // indirect
30 | github.com/dustin/go-humanize v1.0.1 // indirect
31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32 | github.com/go-logfmt/logfmt v0.6.0 // indirect
33 | github.com/google/uuid v1.6.0 // indirect
34 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
35 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
36 | github.com/mattn/go-isatty v0.0.20 // indirect
37 | github.com/mattn/go-localereader v0.0.1 // indirect
38 | github.com/mattn/go-runewidth v0.0.16 // indirect
39 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
40 | github.com/muesli/cancelreader v0.2.2 // indirect
41 | github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 // indirect
42 | github.com/ncruces/go-strftime v0.1.9 // indirect
43 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
44 | github.com/rivo/uniseg v0.4.7 // indirect
45 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
46 | golang.org/x/crypto v0.26.0 // indirect
47 | golang.org/x/sync v0.8.0 // indirect
48 | golang.org/x/sys v0.26.0 // indirect
49 | golang.org/x/text v0.19.0 // indirect
50 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
51 | modernc.org/libc v1.55.3 // indirect
52 | modernc.org/mathutil v1.6.0 // indirect
53 | modernc.org/memory v1.8.0 // indirect
54 | modernc.org/strutil v1.2.0 // indirect
55 | modernc.org/token v1.1.0 // indirect
56 | )
57 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/adrg/xdg v0.5.2 h1:HNeVffMIG56GLMaoKTcTcyFhD2xS/dhyuBlKSNCM6Ug=
2 | github.com/adrg/xdg v0.5.2/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
3 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
4 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
7 | github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm4vBfHsIc=
8 | github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E=
9 | github.com/charmbracelet/keygen v0.5.1 h1:zBkkYPtmKDVTw+cwUyY6ZwGDhRxXkEp0Oxs9sqMLqxI=
10 | github.com/charmbracelet/keygen v0.5.1/go.mod h1:zznJVmK/GWB6dAtjluqn2qsttiCBhA5MZSiwb80fcHw=
11 | github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A=
12 | github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U=
13 | github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
14 | github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
15 | github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa h1:6rePgmsJguB6Z7Y55stsEVDlWFJoUpQvOX4mdnBjgx4=
16 | github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa/go.mod h1:LmMZag2g7ILMmWtDmU7dIlctUopwmb73KpPzj0ip1uk=
17 | github.com/charmbracelet/wish v1.4.3 h1:7FvNLoPGqiT7EdjQP4+XuvM1Hrnx9DyknilbD+Okx1s=
18 | github.com/charmbracelet/wish v1.4.3/go.mod h1:hVgmhwhd52fLmO6m5AkREUMZYqQ0qmIJQDMe3HsNPmU=
19 | github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
20 | github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
21 | github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
22 | github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
23 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
24 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
25 | github.com/charmbracelet/x/input v0.2.0 h1:1Sv+y/flcqUfUH2PXNIDKDIdT2G8smOnGOgawqhwy8A=
26 | github.com/charmbracelet/x/input v0.2.0/go.mod h1:KUSFIS6uQymtnr5lHVSOK9j8RvwTD4YHnWnzJUYnd/M=
27 | github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
28 | github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
29 | github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
30 | github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U=
31 | github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
32 | github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
33 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
34 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
35 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
36 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
37 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
38 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
39 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
40 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
41 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
42 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
43 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
44 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
45 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
46 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
47 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
48 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
49 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
50 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
51 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
52 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
53 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
54 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
55 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
56 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
57 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
58 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
59 | github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 h1:NiONcKK0EV5gUZcnCiPMORaZA0eBDc+Fgepl9xl4lZ8=
60 | github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
61 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
62 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
63 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
64 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
65 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
66 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
67 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
68 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
69 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
70 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
71 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
72 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
73 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
74 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
75 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
76 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
77 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
78 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
79 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
80 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
81 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
82 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
83 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
84 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
85 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
86 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
87 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
88 | golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
89 | golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
90 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
91 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
92 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
93 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
94 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
95 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
96 | modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
97 | modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
98 | modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
99 | modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
100 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
101 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
102 | modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
103 | modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
104 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
105 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
106 | modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
107 | modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
108 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
109 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
110 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
111 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
112 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
113 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
114 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
115 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
116 | modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
117 | modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
118 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
119 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
120 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
121 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
122 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "flag"
7 | "os"
8 | "os/signal"
9 | "path/filepath"
10 | "syscall"
11 | "time"
12 |
13 | "database/sql"
14 |
15 | "github.com/adrg/xdg"
16 | "github.com/ajeetdsouza/clidle/store"
17 | tea "github.com/charmbracelet/bubbletea"
18 | "github.com/charmbracelet/ssh"
19 | "github.com/charmbracelet/wish"
20 | wtea "github.com/charmbracelet/wish/bubbletea"
21 | "github.com/pkg/errors"
22 |
23 | "golang.org/x/exp/slog"
24 | _ "modernc.org/sqlite"
25 | )
26 |
27 | var (
28 | // pathClidle is the path to the local data directory.
29 | // This is usually set to ~/.local/share/clidle on most UNIX systems.
30 | pathClidle string
31 | pathStore string
32 | pathHostKey string
33 |
34 | //go:embed schema.sql
35 | schemaSQL string
36 |
37 | // Default Bubbletea options.
38 | teaOptions []tea.ProgramOption = []tea.ProgramOption{
39 | tea.WithAltScreen(),
40 | tea.WithOutput(os.Stderr),
41 | }
42 | )
43 |
44 | func init() {
45 | pathClidle = os.Getenv("CLIDLE_DATA_DIR")
46 | if pathClidle == "" {
47 | pathClidle = filepath.Join(xdg.DataHome, "clidle")
48 | }
49 |
50 | pathStore = filepath.Join(pathClidle, "clidle.db")
51 | pathHostKey = filepath.Join(pathClidle, "hostkey")
52 | }
53 |
54 | func main() {
55 | flagServe := flag.String("serve", "", "Spawns an SSH server on the given address (format: 0.0.0.0:1337)")
56 | flag.Parse()
57 |
58 | var err error
59 | if addr := *flagServe; addr != "" {
60 | err = runServer(addr)
61 | } else {
62 | err = runCLI()
63 | }
64 | if err != nil {
65 | slog.Error("error running application", "error", slog.Any("error", err))
66 | os.Exit(1)
67 | }
68 | }
69 |
70 | func runCLI() error {
71 | ctx := context.Background()
72 | model, err := getModel(ctx)
73 | if err != nil {
74 | return err
75 | }
76 | program := tea.NewProgram(model, teaOptions...)
77 |
78 | _, err = program.Run()
79 | return err
80 | }
81 |
82 | func runServer(addr string) error {
83 | server, err := wish.NewServer(
84 | wish.WithAddress(addr),
85 | wish.WithIdleTimeout(30*time.Minute),
86 | wish.WithMiddleware(
87 | wtea.Middleware(func(session ssh.Session) (tea.Model, []tea.ProgramOption) {
88 | pty, _, active := session.Pty()
89 | if !active {
90 | wish.Fatalf(session, "no active terminal, skipping")
91 | }
92 |
93 | ctx := session.Context()
94 | model, err := getModel(ctx)
95 | if err != nil {
96 | slog.Error("could not create model", slog.Any("error", err))
97 | wish.Fatalf(session, "could not create model: %v\n", err)
98 | }
99 | model.windowWidth = pty.Window.Width
100 | model.windowHeight = pty.Window.Height
101 |
102 | return model, teaOptions
103 | }),
104 | ),
105 | wish.WithHostKeyPath(pathHostKey),
106 | )
107 | if err != nil {
108 | return errors.Wrapf(err, "could not create server")
109 | }
110 |
111 | done := make(chan os.Signal, 1)
112 | signal.Notify(done, os.Interrupt, syscall.SIGTERM)
113 |
114 | slog.Info("starting server", slog.String("address", server.Addr))
115 | go func() {
116 | if err := server.ListenAndServe(); err != nil {
117 | slog.Error("server returned an error", slog.Any("error", err))
118 | done <- os.Interrupt
119 | }
120 | }()
121 |
122 | <-done
123 | slog.Info("stopping server")
124 |
125 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
126 | defer cancel()
127 |
128 | err = server.Shutdown(ctx)
129 | return errors.Wrapf(err, "could not shutdown server")
130 | }
131 |
132 | func getModel(ctx context.Context) (*model, error) {
133 | dictionary := EnglishDictionary
134 | store, err := getStore()
135 | if err != nil {
136 | return nil, err
137 | }
138 | return newModel(ctx, store, dictionary), nil
139 | }
140 |
141 | func getStore() (*store.Queries, error) {
142 | if err := os.MkdirAll(pathClidle, 0700); err != nil {
143 | return nil, err
144 | }
145 |
146 | db, err := sql.Open("sqlite", pathStore)
147 | if err != nil {
148 | return nil, err
149 | }
150 | db.SetMaxOpenConns(1) // SQLite does not support concurrent writes
151 | if _, err := db.Exec(schemaSQL); err != nil {
152 | return nil, err
153 | }
154 | return store.New(db), nil
155 | }
156 |
--------------------------------------------------------------------------------
/model.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "database/sql"
7 | "fmt"
8 | "time"
9 |
10 | "log/slog"
11 |
12 | "github.com/ajeetdsouza/clidle/store"
13 | tea "github.com/charmbracelet/bubbletea"
14 | "github.com/charmbracelet/lipgloss"
15 | )
16 |
17 | const (
18 | // _numGuesses is the maximum number of guesses you can make.
19 | _numGuesses = 6
20 | // _numChars is the word size in characters.
21 | _numChars = 5
22 | )
23 |
24 | type model struct {
25 | ctx context.Context
26 | store *store.Queries
27 | dictionary Dictionary
28 |
29 | gameID int
30 | gameOver bool
31 |
32 | score int
33 | answer [_numChars]byte
34 |
35 | status string
36 | statusPending int
37 |
38 | windowHeight int
39 | windowWidth int
40 |
41 | grid [_numGuesses][_numChars]byte
42 | gridRow int
43 | gridCol int
44 | keyStates map[byte]keyState
45 | }
46 |
47 | var _ tea.Model = (*model)(nil)
48 |
49 | func newModel(ctx context.Context, store *store.Queries, dictionary Dictionary) *model {
50 | return &model{
51 | ctx: ctx,
52 | store: store,
53 | dictionary: dictionary,
54 | keyStates: make(map[byte]keyState, 26),
55 | }
56 | }
57 |
58 | // Init is the first function that is called when the UI is created.
59 | func (m *model) Init() tea.Cmd {
60 | m.doRestart()
61 | return nil
62 | }
63 |
64 | // Update is called when a message is received. It inspects messages and, in response,
65 | // updates the Model and sends a command.
66 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
67 | switch msg := msg.(type) {
68 | case msgResetStatus:
69 | // If there is more than one pending status message, that means
70 | // something else is currently displaying a status message, so we don't
71 | // want to overwrite it.
72 | m.statusPending--
73 | if m.statusPending == 0 {
74 | m.resetStatus()
75 | }
76 | return m, nil
77 | case tea.KeyMsg:
78 | // If any key is pressed, reset the status message.
79 | m.resetStatus()
80 |
81 | switch msg.Type {
82 | case tea.KeyCtrlC:
83 | return m, m.doExit()
84 | case tea.KeyCtrlR:
85 | m.doRestart()
86 | return m, nil
87 | case tea.KeyBackspace:
88 | return m, m.doDeleteChar()
89 | case tea.KeyEnter:
90 | if m.gameOver {
91 | m.doRestart()
92 | return m, nil
93 | }
94 | return m, m.doAcceptGuess()
95 | case tea.KeyRunes:
96 | if len(msg.Runes) == 1 {
97 | return m, m.doAcceptChar(msg.Runes[0])
98 | }
99 | }
100 | case tea.WindowSizeMsg:
101 | // If the window is resized, store its new dimensions.
102 | return m, m.doResize(msg)
103 | }
104 | return m, nil
105 | }
106 |
107 | func (m *model) View() string {
108 | status := m.viewStatus()
109 | grid := m.viewGrid()
110 | keyboard := m.viewKeyboard()
111 |
112 | // Truncate the status if it is too long.
113 | if len(status) > m.windowWidth && m.windowWidth > 3 {
114 | status = status[:m.windowWidth-3] + "..."
115 | }
116 |
117 | // Drop the keyboard if it doesn't fit.
118 | height := lipgloss.Height(status) + lipgloss.Height(grid) + lipgloss.Height(keyboard)
119 | width := lipgloss.Width(keyboard)
120 | if width < lipgloss.Width(status) || width < lipgloss.Width(grid) {
121 | width = 0
122 | }
123 | if m.windowHeight < height || m.windowWidth < width {
124 | keyboard = ""
125 | }
126 |
127 | game := lipgloss.JoinVertical(lipgloss.Center, status, grid, keyboard, _controls)
128 | return lipgloss.Place(m.windowWidth, m.windowHeight, lipgloss.Center, lipgloss.Center, game)
129 | }
130 |
131 | // doAcceptGuess accepts the current word.
132 | func (m *model) doAcceptGuess() tea.Cmd {
133 | if m.gameOver {
134 | return nil
135 | }
136 |
137 | // Only accept a word if it is complete.
138 | if m.gridCol != _numChars {
139 | return m.setStatus("Your guess must be a 5-letter word.", 1*time.Second)
140 | }
141 |
142 | // Check if the input guess is valid.
143 | guess := m.grid[m.gridRow]
144 | if !m.dictionary.IsWord(string(guess[:])) {
145 | return m.setStatus("That's not a valid word.", 1*time.Second)
146 | }
147 |
148 | // Save the guess.
149 | if err := m.saveGuess(string(guess[:])); err != nil {
150 | slog.Error("error saving guess", slog.Any("error", err))
151 | }
152 |
153 | // Update the state of the used letters.
154 | success := true
155 | for idx, key := range guess {
156 | keyState := _keyStateAbsent
157 | if key == m.answer[idx] {
158 | keyState = _keyStateCorrect
159 | } else {
160 | success = false
161 | if bytes.IndexByte(m.answer[:], key) != -1 {
162 | keyState = _keyStatePresent
163 | }
164 | }
165 | m.keyStates[key] = max(keyState, m.keyStates[key])
166 | }
167 |
168 | // Move the cursor to the next row.
169 | m.gridRow++
170 | m.gridCol = 0
171 |
172 | // Check if the game is over.
173 | if success {
174 | return m.doWin()
175 | } else if m.gridRow == _numGuesses {
176 | return m.doLoss()
177 | }
178 |
179 | return nil
180 | }
181 |
182 | func (m *model) saveGuess(guess string) error {
183 | ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second)
184 | defer cancel()
185 |
186 | // Create a new game if one doesn't exist.
187 | if m.gameID == 0 {
188 | answer := sql.NullString{String: string(m.answer[:]), Valid: true}
189 | game, err := m.store.CreateGame(ctx, answer)
190 | if err != nil {
191 | return err
192 | }
193 | m.gameID = int(game.ID)
194 | }
195 |
196 | params := store.CreateGuessParams{
197 | GameID: sql.NullInt64{Int64: int64(m.gameID), Valid: true},
198 | Guess: sql.NullString{String: guess, Valid: true},
199 | }
200 | _, err := m.store.CreateGuess(ctx, params)
201 | if err != nil {
202 | return err
203 | }
204 |
205 | return nil
206 | }
207 |
208 | // doAcceptChar adds one input character to the current word.
209 | func (m *model) doAcceptChar(ch rune) tea.Cmd {
210 | // Only accept a character if the current word is incomplete.
211 | if m.gameOver || !(m.gridRow < _numGuesses && m.gridCol < _numChars) {
212 | return nil
213 | }
214 |
215 | ch = toAsciiUpper(ch)
216 | if isAsciiUpper(ch) {
217 | m.grid[m.gridRow][m.gridCol] = byte(ch)
218 | m.gridCol++
219 | }
220 | return nil
221 | }
222 |
223 | // doDeleteChar deletes the last character in the current word.
224 | func (m *model) doDeleteChar() tea.Cmd {
225 | if !m.gameOver && m.gridCol > 0 {
226 | m.gridCol--
227 | }
228 | return nil
229 | }
230 |
231 | // doExit exits the program.
232 | func (*model) doExit() tea.Cmd {
233 | return tea.Quit
234 | }
235 |
236 | // doResize updates the size of the window.
237 | func (m *model) doResize(msg tea.WindowSizeMsg) tea.Cmd {
238 | m.windowHeight = msg.Height
239 | m.windowWidth = msg.Width
240 | return nil
241 | }
242 |
243 | // doWin is called when the user has guessed the word correctly.
244 | func (m *model) doWin() tea.Cmd {
245 | m.gameOver = true
246 | m.updateScore()
247 | return m.setStatus("You win!", 0)
248 | }
249 |
250 | // doLoss is called when the user has used up all their guesses.
251 | func (m *model) doLoss() tea.Cmd {
252 | m.gameOver = true
253 | m.updateScore()
254 | msg := fmt.Sprintf("The word was %s. Better luck next time!", string(m.answer[:]))
255 | return m.setStatus(msg, 0)
256 | }
257 |
258 | // doRestart resets the game state and starts a new game.
259 | func (m *model) doRestart() {
260 | // Start a new game.
261 | m.gameID = 0
262 | m.gameOver = false
263 |
264 | // Set the puzzle answer.
265 | answer := m.dictionary.GetRandomCommonWord()
266 | copy(m.answer[:], answer)
267 |
268 | // Reset the grid.
269 | m.gridCol = 0
270 | m.gridRow = 0
271 |
272 | // Clear the key state.
273 | for k := range m.keyStates {
274 | delete(m.keyStates, k)
275 | }
276 |
277 | // Reset the status message.
278 | m.updateScore()
279 | m.resetStatus()
280 | }
281 |
282 | // updateScore fetches the current total score from the database.
283 | func (m *model) updateScore() {
284 | ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second)
285 | defer cancel()
286 |
287 | score, err := m.store.GetTotalScore(ctx)
288 | if err != nil {
289 | slog.Error("error fetching score", slog.Any("error", err))
290 | return
291 | }
292 | m.score = int(score.Float64)
293 | }
294 |
295 | // setStatus sets the status message, and returns a tea.Cmd that restores the
296 | // default status message after a delay.
297 | func (m *model) setStatus(msg string, duration time.Duration) tea.Cmd {
298 | m.status = msg
299 | if duration > 0 {
300 | m.statusPending++
301 | return tea.Tick(duration, func(time.Time) tea.Msg {
302 | return msgResetStatus{}
303 | })
304 | }
305 | return nil
306 | }
307 |
308 | // resetStatus immediately resets the status message to its default value.
309 | func (m *model) resetStatus() {
310 | m.status = fmt.Sprintf("Score: %d", m.score)
311 | }
312 |
313 | // viewStatus renders the status line.
314 | func (m *model) viewStatus() string {
315 | return lipgloss.NewStyle().Foreground(_colorPrimary).Render(m.status)
316 | }
317 |
318 | // viewGrid renders the grid.
319 | func (m *model) viewGrid() string {
320 | var rows [_numGuesses]string
321 | for i := 0; i < _numGuesses; i++ {
322 | if i < m.gridRow {
323 | rows[i] = m.viewGridRowFilled(m.grid[i])
324 | } else if i == m.gridRow && !m.gameOver {
325 | rows[i] = m.viewGridRowCurrent(m.grid[i], m.gridCol)
326 | } else {
327 | rows[i] = m.viewGridRowEmpty()
328 | }
329 | }
330 | return lipgloss.JoinVertical(lipgloss.Left, rows[:]...)
331 | }
332 |
333 | // viewGridRowFilled renders a filled-in grid row. It chooses the appropriate
334 | // color for each key.
335 | func (m *model) viewGridRowFilled(word [_numChars]byte) string {
336 | var keyStates [_numChars]keyState
337 | letters := m.answer
338 |
339 | // Mark keyStatusAbsent.
340 | for i := 0; i < _numChars; i++ {
341 | keyStates[i] = _keyStateAbsent
342 | }
343 |
344 | // Mark keyStatusCorrect.
345 | for i := 0; i < _numChars; i++ {
346 | if word[i] == m.answer[i] {
347 | keyStates[i] = _keyStateCorrect
348 | letters[i] = 0
349 | }
350 | }
351 |
352 | // Mark keyStatusPresent.
353 | for i := 0; i < _numChars; i++ {
354 | if keyStates[i] == _keyStateCorrect {
355 | continue
356 | }
357 | if foundIdx := bytes.IndexByte(letters[:], word[i]); foundIdx != -1 {
358 | keyStates[i] = _keyStatePresent
359 | letters[foundIdx] = 0
360 | }
361 | }
362 |
363 | // Render keys.
364 | var keys [_numChars]string
365 | for i := 0; i < _numChars; i++ {
366 | keys[i] = m.viewKey(string(word[i]), keyStates[i].color())
367 | }
368 | return lipgloss.JoinHorizontal(lipgloss.Bottom, keys[:]...)
369 | }
370 |
371 | // viewGridRowCurrent renders the current grid row. It renders an "_" character
372 | // for the letter being currently input.
373 | func (m *model) viewGridRowCurrent(row [_numChars]byte, rowIdx int) string {
374 | var keys [_numChars]string
375 | for i := 0; i < _numChars; i++ {
376 | var key string
377 | if i < rowIdx {
378 | key = string(row[i])
379 | } else if i == rowIdx {
380 | key = "_"
381 | } else {
382 | key = " "
383 | }
384 | keys[i] = m.viewKey(key, _colorPrimary)
385 | }
386 | return lipgloss.JoinHorizontal(lipgloss.Bottom, keys[:]...)
387 | }
388 |
389 | // viewGridRowEmpty renders an empty grid row. If the grid is locked, the keys
390 | // are grayed out.
391 | func (m *model) viewGridRowEmpty() string {
392 | keyState := _keyStateUnselected
393 | if m.gameOver {
394 | keyState = _keyStateAbsent
395 | }
396 | key := m.viewKey(" ", keyState.color())
397 | keys := [_numChars]string{key, key, key, key, key}
398 | return lipgloss.JoinHorizontal(lipgloss.Bottom, keys[:]...)
399 | }
400 |
401 | // viewKeyboard renders the entire keyboard, including a border. It chooses the
402 | // appropriate color for keys that have been guessed before.
403 | func (m *model) viewKeyboard() string {
404 | topRow := m.viewKeyboardRow([]string{"Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"})
405 | midRow := m.viewKeyboardRow([]string{"A", "S", "D", "F", "G", "H", "J", "K", "L"})
406 | botRow := m.viewKeyboardRow([]string{"ENTER", "Z", "X", "C", "V", "B", "N", "M", "DELETE"})
407 | keys := lipgloss.JoinVertical(
408 | lipgloss.Left,
409 | lipgloss.NewStyle().Padding(0, 2).Render(topRow),
410 | lipgloss.NewStyle().Padding(0, 4).Render(midRow),
411 | botRow,
412 | )
413 | return lipgloss.NewStyle().
414 | Border(lipgloss.NormalBorder()).
415 | BorderForeground(_keyStateUnselected.color()).
416 | Padding(0, 1).
417 | Render(keys)
418 | }
419 |
420 | // viewKeyboardRow renders a single row of the keyboard. It chooses the
421 | // appropriate color for keys that have been guessed before.
422 | func (m *model) viewKeyboardRow(keys []string) string {
423 | keysRendered := make([]string, len(keys))
424 | for _, key := range keys {
425 | status := _keyStateUnselected
426 | if len(key) == 1 {
427 | key := key[0]
428 | status = m.keyStates[key]
429 | }
430 | keysRendered = append(keysRendered, m.viewKey(key, status.color()))
431 | }
432 | return lipgloss.JoinHorizontal(lipgloss.Bottom, keysRendered...)
433 | }
434 |
435 | // viewKey renders a key with the given name and color.
436 | func (*model) viewKey(key string, color lipgloss.TerminalColor) string {
437 | return lipgloss.NewStyle().
438 | Padding(0, 1).
439 | Border(lipgloss.NormalBorder()).
440 | BorderForeground(color).
441 | Foreground(color).
442 | Render(key)
443 | }
444 |
445 | // msgResetStatus is sent when the status line should be reset.
446 | type msgResetStatus struct{}
447 |
448 | const (
449 | _colorPrimary = lipgloss.Color("#d7dadc")
450 | _colorSecondary = lipgloss.Color("#626262")
451 | _colorSeparator = lipgloss.Color("#9c9c9c")
452 | _colorYellow = lipgloss.Color("#b59f3b")
453 | _colorGreen = lipgloss.Color("#538d4e")
454 | )
455 |
456 | // keyState represents the state of a key.
457 | type keyState int
458 |
459 | const (
460 | _keyStateUnselected keyState = iota
461 | _keyStateAbsent
462 | _keyStatePresent
463 | _keyStateCorrect
464 | )
465 |
466 | // color returns the appropriate dark mode color for the given key state.
467 | func (s keyState) color() lipgloss.Color {
468 | switch s {
469 | case _keyStateUnselected:
470 | return _colorPrimary
471 | case _keyStateAbsent:
472 | return _colorSecondary
473 | case _keyStatePresent:
474 | return _colorYellow
475 | case _keyStateCorrect:
476 | return _colorGreen
477 | default:
478 | panic("invalid key status")
479 | }
480 | }
481 |
482 | var _controls = fmt.Sprintf("%s %s %s %s %s",
483 | lipgloss.NewStyle().Foreground(_colorPrimary).Render("ctrl+c"),
484 | lipgloss.NewStyle().Foreground(_colorSecondary).Render("quit"),
485 | lipgloss.NewStyle().Foreground(_colorSeparator).Render("//"),
486 | lipgloss.NewStyle().Foreground(_colorPrimary).Render("ctrl+r"),
487 | lipgloss.NewStyle().Foreground(_colorSecondary).Render("restart"),
488 | )
489 |
490 | // isAsciiUpper checks if a rune is between A-Z.
491 | func isAsciiUpper(r rune) bool {
492 | return 'A' <= r && r <= 'Z'
493 | }
494 |
495 | // toAsciiUpper converts a rune to uppercase if it is between A-Z.
496 | func toAsciiUpper(r rune) rune {
497 | if 'a' <= r && r <= 'z' {
498 | r -= 'a' - 'A'
499 | }
500 | return r
501 | }
502 |
--------------------------------------------------------------------------------
/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ajeetdsouza/clidle/e0e2cb1d3c71d52f7f7294f52c497bf558a605d1/preview.png
--------------------------------------------------------------------------------
/query.sql:
--------------------------------------------------------------------------------
1 | -- name: CreateGame :one
2 | INSERT INTO game (answer)
3 | VALUES (?)
4 | RETURNING *;
5 |
6 | -- name: CreateGuess :one
7 | INSERT INTO guess (game_id, guess)
8 | VALUES (?, ?)
9 | RETURNING *;
10 |
11 | -- name: GetTotalScore :one
12 | WITH game_scores AS (
13 | SELECT game.id, (10 * (11 - COUNT(guess.id))) AS score
14 | FROM game
15 | INNER JOIN guess victory ON game.id = victory.game_id AND game.answer = victory.guess
16 | INNER JOIN guess ON game.id = guess.game_id
17 | GROUP BY game.id
18 | )
19 | SELECT SUM(score) FROM game_scores;
20 |
--------------------------------------------------------------------------------
/schema.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS game (
2 | id INTEGER PRIMARY KEY AUTOINCREMENT,
3 | answer TEXT
4 | );
5 |
6 | CREATE TABLE IF NOT EXISTS guess (
7 | id INTEGER PRIMARY KEY AUTOINCREMENT,
8 | game_id INTEGER REFERENCES game(id),
9 | guess TEXT
10 | );
11 |
--------------------------------------------------------------------------------
/sqlc.yaml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | sql:
3 | - engine: "sqlite"
4 | queries: "query.sql"
5 | schema: "schema.sql"
6 | gen:
7 | go:
8 | package: "store"
9 | out: "store"
10 |
--------------------------------------------------------------------------------
/store/db.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 |
5 | package store
6 |
7 | import (
8 | "context"
9 | "database/sql"
10 | )
11 |
12 | type DBTX interface {
13 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
14 | PrepareContext(context.Context, string) (*sql.Stmt, error)
15 | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
16 | QueryRowContext(context.Context, string, ...interface{}) *sql.Row
17 | }
18 |
19 | func New(db DBTX) *Queries {
20 | return &Queries{db: db}
21 | }
22 |
23 | type Queries struct {
24 | db DBTX
25 | }
26 |
27 | func (q *Queries) WithTx(tx *sql.Tx) *Queries {
28 | return &Queries{
29 | db: tx,
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/store/models.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 |
5 | package store
6 |
7 | import (
8 | "database/sql"
9 | )
10 |
11 | type Game struct {
12 | ID int64
13 | Answer sql.NullString
14 | }
15 |
16 | type Guess struct {
17 | ID int64
18 | GameID sql.NullInt64
19 | Guess sql.NullString
20 | }
21 |
--------------------------------------------------------------------------------
/store/query.sql.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 | // source: query.sql
5 |
6 | package store
7 |
8 | import (
9 | "context"
10 | "database/sql"
11 | )
12 |
13 | const createGame = `-- name: CreateGame :one
14 | INSERT INTO game (answer)
15 | VALUES (?)
16 | RETURNING id, answer
17 | `
18 |
19 | func (q *Queries) CreateGame(ctx context.Context, answer sql.NullString) (Game, error) {
20 | row := q.db.QueryRowContext(ctx, createGame, answer)
21 | var i Game
22 | err := row.Scan(&i.ID, &i.Answer)
23 | return i, err
24 | }
25 |
26 | const createGuess = `-- name: CreateGuess :one
27 | INSERT INTO guess (game_id, guess)
28 | VALUES (?, ?)
29 | RETURNING id, game_id, guess
30 | `
31 |
32 | type CreateGuessParams struct {
33 | GameID sql.NullInt64
34 | Guess sql.NullString
35 | }
36 |
37 | func (q *Queries) CreateGuess(ctx context.Context, arg CreateGuessParams) (Guess, error) {
38 | row := q.db.QueryRowContext(ctx, createGuess, arg.GameID, arg.Guess)
39 | var i Guess
40 | err := row.Scan(&i.ID, &i.GameID, &i.Guess)
41 | return i, err
42 | }
43 |
44 | const getTotalScore = `-- name: GetTotalScore :one
45 | WITH game_scores AS (
46 | SELECT game.id, (10 * (11 - COUNT(guess.id))) AS score
47 | FROM game
48 | INNER JOIN guess victory ON game.id = victory.game_id AND game.answer = victory.guess
49 | INNER JOIN guess ON game.id = guess.game_id
50 | GROUP BY game.id
51 | )
52 | SELECT SUM(score) FROM game_scores
53 | `
54 |
55 | func (q *Queries) GetTotalScore(ctx context.Context) (sql.NullFloat64, error) {
56 | row := q.db.QueryRowContext(ctx, getTotalScore)
57 | var sum sql.NullFloat64
58 | err := row.Scan(&sum)
59 | return sum, err
60 | }
61 |
--------------------------------------------------------------------------------