├── .github └── workflows │ └── go.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── internal └── solitaire │ └── solitaire.go ├── main.go └── pkg ├── card.go ├── card_test.go ├── deck.go └── deck_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.19 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solitaire TUI 2 | 3 |

4 | screenshot 5 | screenshot 6 |

7 | 8 | 🧋 Made with [bubbletea](https://github.com/charmbracelet/bubbletea). 9 | 10 | ## Installation 11 | 12 | ```bash 13 | go install github.com/brianstrauch/solitaire-tui@latest 14 | ``` 15 | 16 | ## Troubleshooting 17 | 18 | You'll want to set your terminal's line spacing to 1.0 to avoid gaps within the playing cards. 19 | 20 | 21 | 22 | 23 | 28 | 29 |
Intellij 24 | Settings > Editor > Color Scheme > Console Font
25 | ✅ Use console font instead of the default
26 | Line height: 1.0 27 |
30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/brianstrauch/solitaire-tui 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v0.23.1 7 | github.com/charmbracelet/lipgloss v0.6.0 8 | github.com/stretchr/testify v1.8.1 9 | ) 10 | 11 | require ( 12 | github.com/aymanbagabas/go-osc52 v1.0.3 // indirect 13 | github.com/containerd/console v1.0.3 // indirect 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 16 | github.com/mattn/go-isatty v0.0.16 // indirect 17 | github.com/mattn/go-localereader v0.0.1 // indirect 18 | github.com/mattn/go-runewidth v0.0.14 // indirect 19 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 20 | github.com/muesli/cancelreader v0.2.2 // indirect 21 | github.com/muesli/reflow v0.3.0 // indirect 22 | github.com/muesli/termenv v0.13.0 // indirect 23 | github.com/pmezard/go-difflib v1.0.0 // indirect 24 | github.com/rivo/uniseg v0.2.0 // indirect 25 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect 26 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 27 | golang.org/x/text v0.3.7 // indirect 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= 2 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 3 | github.com/charmbracelet/bubbletea v0.23.1 h1:CYdteX1wCiCzKNUlwm25ZHBIc1GXlYFyUIte8WPvhck= 4 | github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= 5 | github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= 6 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 7 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 8 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 13 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 14 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 15 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 16 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 17 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 18 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 19 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 20 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 21 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 22 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 23 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 24 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 25 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 26 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 27 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 28 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 29 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 30 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 31 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 32 | github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= 33 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 34 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 35 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 38 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 39 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 40 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 41 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 42 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 43 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 44 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 45 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 46 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 47 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= 51 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 53 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 54 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 55 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 56 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 57 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 59 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 60 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 61 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 62 | -------------------------------------------------------------------------------- /internal/solitaire/solitaire.go: -------------------------------------------------------------------------------- 1 | package solitaire 2 | 3 | import ( 4 | "github.com/brianstrauch/solitaire-tui/pkg" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | type deckType int 11 | 12 | const ( 13 | stock deckType = 0 14 | waste deckType = 1 15 | empty deckType = 2 16 | foundation deckType = 3 17 | tableau deckType = 7 18 | ) 19 | 20 | var deckTypes = []deckType{ 21 | stock, 22 | waste, 23 | empty, 24 | foundation, 25 | foundation, 26 | foundation, 27 | foundation, 28 | tableau, 29 | tableau, 30 | tableau, 31 | tableau, 32 | tableau, 33 | tableau, 34 | tableau, 35 | } 36 | 37 | type Solitaire struct { 38 | decks []*pkg.Deck 39 | selected *index 40 | mouse tea.MouseMsg 41 | } 42 | 43 | type index struct { 44 | deck int 45 | card int 46 | } 47 | 48 | func New() *Solitaire { 49 | decks := make([]*pkg.Deck, len(deckTypes)) 50 | for i := range decks { 51 | switch deckTypes[i] { 52 | case stock: 53 | decks[i] = pkg.NewFullDeck() 54 | case empty: 55 | decks[i] = nil 56 | default: 57 | decks[i] = pkg.NewEmptyDeck() 58 | } 59 | } 60 | 61 | for i := int(tableau); i < len(decks); i++ { 62 | deck := decks[i] 63 | for j := int(tableau); j <= i; j++ { 64 | deck.Add(decks[stock].Pop()) 65 | } 66 | deck.Top().Flip() 67 | deck.Expand() 68 | } 69 | 70 | return &Solitaire{decks: decks} 71 | } 72 | 73 | func (s *Solitaire) Init() tea.Cmd { 74 | return tea.ClearScreen 75 | } 76 | 77 | func (s *Solitaire) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 78 | switch msg := msg.(type) { 79 | case tea.KeyMsg: 80 | switch msg.String() { 81 | case "q", "ctrl+c", "esc": 82 | return s, tea.Quit 83 | } 84 | case tea.MouseMsg: 85 | switch msg.Type { 86 | case tea.MouseLeft: 87 | if s.mouse.Type != tea.MouseLeft { 88 | s.mouse = msg 89 | } 90 | case tea.MouseRelease: 91 | if s.mouse.Type == tea.MouseLeft && msg.X == s.mouse.X && msg.Y == s.mouse.Y { 92 | s.click(msg.X, msg.Y) 93 | } 94 | s.mouse = msg 95 | } 96 | } 97 | 98 | return s, nil 99 | } 100 | 101 | func (s *Solitaire) click(x, y int) { 102 | n := len(s.decks) / 2 103 | 104 | for i, deck := range s.decks { 105 | xi := (i % n) * pkg.Width 106 | yi := (i / n) * pkg.Height 107 | 108 | if ok, j := deck.IsClicked(x-xi, y-yi); ok { 109 | switch deckTypes[i] { 110 | case stock: 111 | if deck.Size() > 0 { 112 | s.draw(1, deck, s.decks[waste]) 113 | } else { 114 | s.draw(s.decks[waste].Size(), s.decks[waste], deck) 115 | } 116 | case waste: 117 | if deck.Size() > 0 { 118 | if s.selected != nil && s.selected.deck != i { 119 | s.toggleSelect(nil) 120 | } 121 | s.toggleSelect(&index{deck: i, card: deck.Size() - 1}) 122 | } 123 | case foundation: 124 | if s.selected != nil && s.selected.deck != i { 125 | if !s.move(&index{deck: i}) && deck.Size() > 0 { 126 | s.toggleSelect(nil) 127 | s.toggleSelect(&index{deck: i, card: deck.Size() - 1}) 128 | } 129 | } else if deck.Size() > 0 { 130 | if s.selected != nil && s.selected.deck != i { 131 | s.toggleSelect(nil) 132 | } 133 | s.toggleSelect(&index{deck: i, card: deck.Size() - 1}) 134 | } 135 | case tableau: 136 | if j == deck.Size()-1 && !deck.Top().IsVisible { 137 | if s.selected != nil { 138 | s.toggleSelect(s.selected) 139 | } 140 | deck.Top().Flip() 141 | } else if s.selected != nil && s.selected.deck != i { 142 | if !s.move(&index{deck: i, card: j}) && deck.Size() > 0 { 143 | s.toggleSelect(nil) 144 | s.toggleSelect(&index{deck: i, card: j}) 145 | } 146 | } else if deck.Size() > 0 && deck.Get(j).IsVisible { 147 | if s.selected != nil && s.selected.deck == i && s.selected.card != j { 148 | s.toggleSelect(&index{deck: i, card: j}) 149 | } 150 | s.toggleSelect(&index{deck: i, card: j}) 151 | } 152 | } 153 | 154 | break 155 | } 156 | } 157 | } 158 | 159 | func (s *Solitaire) draw(n int, from, to *pkg.Deck) { 160 | if s.selected != nil { 161 | s.toggleSelect(s.selected) 162 | } 163 | 164 | for i := 0; i < n; i++ { 165 | if card := from.Pop(); card != nil { 166 | card.Flip() 167 | to.Add(card) 168 | } 169 | } 170 | } 171 | 172 | func (s *Solitaire) move(to *index) bool { 173 | toDeck := s.decks[to.deck] 174 | fromDeck := s.decks[s.selected.deck] 175 | fromCards := fromDeck.GetFrom(s.selected.card) 176 | 177 | switch deckTypes[to.deck] { 178 | case foundation: 179 | if s.selected.card == fromDeck.Size()-1 && toDeck.Size() == 0 && fromDeck.Top().Value == 0 || toDeck.Size() > 0 && fromDeck.Top().Value == toDeck.Top().Value+1 && fromDeck.Top().Suit == toDeck.Top().Suit { 180 | s.toggleSelect(s.selected) 181 | toDeck.Add(fromDeck.Pop()) 182 | s.selected = nil 183 | return true 184 | } 185 | case tableau: 186 | if toDeck.Size() == 0 && fromCards[0].Value == 12 || toDeck.Size() > 0 && fromCards[0].Value+1 == toDeck.Top().Value && fromCards[0].Color() != toDeck.Top().Color() { 187 | idx := s.selected.card 188 | s.toggleSelect(s.selected) 189 | toDeck.Add(fromDeck.PopFrom(idx)...) 190 | s.selected = nil 191 | return true 192 | } 193 | } 194 | 195 | return false 196 | } 197 | 198 | func (s *Solitaire) toggleSelect(selected *index) { 199 | if s.selected != nil { 200 | for _, card := range s.decks[s.selected.deck].GetFrom(s.selected.card) { 201 | card.IsSelected = false 202 | } 203 | s.selected = nil 204 | } else if s.decks[selected.deck].Size() > 0 { 205 | s.selected = selected 206 | for _, card := range s.decks[s.selected.deck].GetFrom(s.selected.card) { 207 | card.IsSelected = true 208 | } 209 | } 210 | } 211 | 212 | func (s *Solitaire) View() string { 213 | n := len(s.decks) / 2 214 | 215 | var view string 216 | for i := 0; i < 2; i++ { 217 | row := make([]string, n) 218 | for j := range row { 219 | row[j] = s.decks[i*n+j].View() 220 | } 221 | view += lipgloss.JoinHorizontal(lipgloss.Top, row...) + "\n" 222 | } 223 | 224 | return view 225 | } 226 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/brianstrauch/solitaire-tui/internal/solitaire" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | func main() { 13 | rand.Seed(time.Now().UnixNano()) 14 | 15 | p := tea.NewProgram(solitaire.New(), tea.WithMouseCellMotion()) 16 | if _, err := p.Run(); err != nil { 17 | log.Fatal(err) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/card.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | var ( 10 | values = []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} 11 | suits = []string{"♠", "♦", "♥", "♣"} 12 | ) 13 | 14 | const ( 15 | Width = 6 16 | Height = 5 17 | ) 18 | 19 | type Card struct { 20 | Value int 21 | Suit int 22 | IsVisible bool 23 | IsSelected bool 24 | } 25 | 26 | func NewCard(value, suit int) *Card { 27 | return &Card{ 28 | Value: value, 29 | Suit: suit, 30 | } 31 | } 32 | 33 | func (c *Card) View() string { 34 | color := lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"} 35 | 36 | if !c.IsVisible { 37 | return viewCard("╱", "", color) 38 | } 39 | 40 | if c.IsSelected { 41 | color = lipgloss.AdaptiveColor{Light: "#FFFF00", Dark: "#00FFFF"} 42 | } 43 | 44 | style := lipgloss.NewStyle().Foreground(c.Color()) 45 | return viewCard(" ", style.Render(c.String()), color) 46 | } 47 | 48 | func (c *Card) Flip() { 49 | c.IsVisible = !c.IsVisible 50 | } 51 | 52 | func (c *Card) Color() lipgloss.AdaptiveColor { 53 | if c.Suit == 0 || c.Suit == 3 { 54 | return lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"} 55 | } else { 56 | return lipgloss.AdaptiveColor{Light: "#FF0000", Dark: "#FF0000"} 57 | } 58 | } 59 | 60 | func (c *Card) String() string { 61 | return values[c.Value] + suits[c.Suit] 62 | } 63 | 64 | func viewCard(design, shorthand string, color lipgloss.AdaptiveColor) string { 65 | style := lipgloss.NewStyle().Foreground(color) 66 | padding := strings.Repeat("─", Width-2-lipgloss.Width(shorthand)) 67 | 68 | view := style.Render("╭") + shorthand + style.Render(padding+"╮") + "\n" 69 | for i := 1; i < Height-1; i++ { 70 | view += style.Render("│"+strings.Repeat(design, Width-2)+"│") + "\n" 71 | } 72 | view += style.Render("╰"+padding) + shorthand + style.Render("╯") 73 | 74 | return view 75 | } 76 | -------------------------------------------------------------------------------- /pkg/card_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestFlip(t *testing.T) { 10 | card := new(Card) 11 | card.Flip() 12 | 13 | require.True(t, card.IsVisible) 14 | } 15 | -------------------------------------------------------------------------------- /pkg/deck.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | type Deck struct { 11 | cards []*Card 12 | isExpanded bool 13 | } 14 | 15 | func NewDeck(cards []*Card) *Deck { 16 | return &Deck{cards: cards} 17 | } 18 | 19 | func NewFullDeck() *Deck { 20 | cards := make([]*Card, len(values)*len(suits)) 21 | for i := range values { 22 | for j := range suits { 23 | cards[i*len(suits)+j] = NewCard(i, j) 24 | } 25 | } 26 | 27 | deck := NewDeck(cards) 28 | deck.Shuffle() 29 | 30 | return deck 31 | } 32 | 33 | func NewEmptyDeck() *Deck { 34 | return NewDeck(make([]*Card, 0)) 35 | } 36 | 37 | func (d *Deck) Shuffle() { 38 | rand.Shuffle(d.Size(), func(i, j int) { 39 | d.cards[i], d.cards[j] = d.cards[j], d.cards[i] 40 | }) 41 | } 42 | 43 | func (d *Deck) Expand() { 44 | d.isExpanded = true 45 | } 46 | 47 | func (d *Deck) View() string { 48 | if d == nil { 49 | return strings.Repeat(" ", Width) 50 | } 51 | 52 | // Outline 53 | if d.Size() == 0 { 54 | return viewCard(" ", "", lipgloss.AdaptiveColor{Light: "#CCCCCC", Dark: "#888888"}) 55 | } 56 | 57 | // Expanded cards 58 | if d.isExpanded { 59 | var view string 60 | for i := 0; i < d.Size()-1; i++ { 61 | view += strings.Split(d.cards[i].View(), "\n")[0] + "\n" 62 | } 63 | return view + d.cards[d.Size()-1].View() 64 | } 65 | 66 | // Top card only 67 | return d.cards[d.Size()-1].View() 68 | } 69 | 70 | func (d *Deck) IsClicked(x, y int) (bool, int) { 71 | if d == nil { 72 | return false, 0 73 | } 74 | 75 | if d.Size() == 0 { 76 | return x >= 0 && x < Width && y >= 0 && y < Height, 0 77 | } 78 | 79 | if d.isExpanded { 80 | for i := d.Size() - 1; i >= 0; i-- { 81 | if x >= 0 && x < Width && y >= i && y < i+Height { 82 | return true, i 83 | } 84 | } 85 | return false, 0 86 | } 87 | 88 | return x >= 0 && x < Width && y >= 0 && y < Height, 0 89 | } 90 | 91 | func (d *Deck) Add(cards ...*Card) { 92 | d.cards = append(d.cards, cards...) 93 | } 94 | 95 | func (d *Deck) Top() *Card { 96 | return d.Get(d.Size() - 1) 97 | } 98 | 99 | func (d *Deck) Bottom() *Card { 100 | return d.Get(0) 101 | } 102 | 103 | func (d *Deck) Get(idx int) *Card { 104 | return d.cards[idx] 105 | } 106 | 107 | func (d *Deck) GetFrom(idx int) []*Card { 108 | return d.cards[idx:] 109 | } 110 | 111 | func (d *Deck) Pop() *Card { 112 | if len(d.cards) > 0 { 113 | return d.PopFrom(d.Size() - 1)[0] 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func (d *Deck) PopFrom(idx int) []*Card { 120 | cards := d.cards[idx:] 121 | d.cards = d.cards[:idx] 122 | return cards 123 | } 124 | 125 | func (d *Deck) Size() int { 126 | return len(d.cards) 127 | } 128 | 129 | // TestDeck is a helper function to simplify testing. 130 | func TestDeck(shorthands ...string) *Deck { 131 | cards := make([]*Card, len(shorthands)) 132 | for i, shorthand := range shorthands { 133 | cards[i] = testCard(shorthand) 134 | } 135 | return &Deck{cards: cards} 136 | } 137 | 138 | func testCard(shorthand string) *Card { 139 | card := &Card{IsVisible: !strings.HasSuffix(shorthand, "?")} 140 | 141 | for i, value := range values { 142 | if strings.HasPrefix(shorthand, value) { 143 | card.Value = i 144 | break 145 | } 146 | } 147 | 148 | for i, suit := range suits { 149 | if strings.Contains(shorthand, suit) { 150 | card.Suit = i 151 | break 152 | } 153 | } 154 | 155 | return card 156 | } 157 | -------------------------------------------------------------------------------- /pkg/deck_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestNewDeck(t *testing.T) { 10 | deck := NewFullDeck() 11 | 12 | expected := TestDeck( 13 | "A♠?", "2♠?", "3♠?", "4♠?", "5♠?", "6♠?", "7♠?", "8♠?", "9♠?", "10♠?", "J♠?", "Q♠?", "K♠?", 14 | "A♦?", "2♦?", "3♦?", "4♦?", "5♦?", "6♦?", "7♦?", "8♦?", "9♦?", "10♦?", "J♦?", "Q♦?", "K♦?", 15 | "A♥?", "2♥?", "3♥?", "4♥?", "5♥?", "6♥?", "7♥?", "8♥?", "9♥?", "10♥?", "J♥?", "Q♥?", "K♥?", 16 | "A♣?", "2♣?", "3♣?", "4♣?", "5♣?", "6♣?", "7♣?", "8♣?", "9♣?", "10♣?", "J♣?", "Q♣?", "K♣?", 17 | ) 18 | 19 | require.ElementsMatch(t, expected.cards, deck.cards) 20 | } 21 | 22 | func TestShuffle(t *testing.T) { 23 | deck := TestDeck("A♠", "2♠") 24 | deck.Shuffle() 25 | 26 | require.ElementsMatch(t, TestDeck("A♠", "2♠").cards, deck.cards) 27 | } 28 | --------------------------------------------------------------------------------