├── .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 | Preview 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 | --------------------------------------------------------------------------------