├── .github ├── ISSUE_TEMPLATE │ ├── 0-feature.md │ ├── 1-bug.md │ ├── 2-documentation.md │ └── 4-refactor.md ├── PULL_REQUEST_TEMPLATE │ ├── 0-feature.md │ ├── 1-bug.md │ ├── 2-documentation.md │ └── 4-refactor.md └── workflows │ └── deploy.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── book ├── .gitignore ├── book.toml └── src │ ├── SUMMARY.md │ ├── cards.md │ ├── configuration.md │ ├── fill_in_the_blanks.md │ ├── flashcard.md │ ├── getting-started.md │ ├── installation.md │ ├── introduction.md │ ├── multiple_answer.md │ ├── multiple_choice.md │ └── order.md ├── example.md └── src ├── main.rs ├── models ├── args.rs ├── card.rs ├── card_types │ ├── fill_in_the_blanks.rs │ ├── flashcard.rs │ ├── mod.rs │ ├── multiple_answer.rs │ ├── multiple_choice.rs │ └── order.rs ├── choice.rs ├── errors │ ├── errors.rs │ ├── file_error.rs │ ├── mod.rs │ └── parsing_error.rs ├── file_type.rs ├── mod.rs ├── stateful_list.rs └── user_answer.rs └── ui.rs /.github/ISSUE_TEMPLATE/0-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "New Feature" 3 | about: "Suggest an idea" 4 | title: "" 5 | labels: "Type: Feature" 6 | assignees: "" 7 | 8 | --- 9 | 10 | 11 | Cc: 12 | 13 | ### Problem 14 | 15 | 16 | ### Suggested Fix 17 | 18 | 19 | ### Tasks 20 | 21 | - [ ] ... 22 | - [ ] ... 23 | 24 | ### More Information 25 | 26 | 27 | ### Screenshots 28 | 29 | 30 | Thanks! 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug Report" 3 | about: "Create a report to help us improve the application" 4 | title: "" 5 | labels: "Type: Bug" 6 | assignees: "" 7 | 8 | --- 9 | 10 | 11 | Cc: 12 | 13 | ### Describe the Bug 14 | 15 | 16 | ### To Reproduce 17 | 18 | Steps to reproduce the behavior: 19 | 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | ### Expected Behavior 26 | 27 | 28 | ### Tasks 29 | 30 | - [ ] Investigate 31 | - [ ] Fix 32 | 33 | ### More Information 34 | 35 | 36 | ### Environment 37 | 38 | - Device: [e.g. iPhone 12] 39 | - Browser: [e.g. chrome, safari] 40 | - OS: [e.g. iOS] 41 | 42 | ### Screenshots 43 | 44 | 45 | Thanks! 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Documentation" 3 | about: "Updating documentation" 4 | title: "" 5 | labels: "Type: Documentation" 6 | assignees: "" 7 | 8 | --- 9 | 10 | 11 | Cc: 12 | 13 | ### Problem 14 | 15 | 16 | ### Suggested Documentation 17 | 18 | 19 | ### Tasks 20 | 21 | - [ ] ... 22 | - [ ] ... 23 | 24 | ### More Information 25 | 26 | 27 | ### Screenshots 28 | 29 | 30 | Thanks! 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4-refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Refactor" 3 | about: "Something that needs improving while not changing the functionality" 4 | title: "" 5 | labels: "Type: Refactor" 6 | assignees: "" 7 | 8 | --- 9 | 10 | 11 | Cc: 12 | 13 | ### Problem 14 | 15 | 16 | ### What code could be improved? 17 | 18 | 19 | ### Tasks 20 | 21 | - [ ] ... 22 | - [ ] ... 23 | 24 | ### More Information 25 | 26 | 27 | ### Screenshots 28 | 29 | 30 | Thanks! 31 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/0-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature" 3 | about: "Implementing a new feature" 4 | title: "" 5 | labels: "Type: Feature" 6 | assignees: "" 7 | 8 | --- 9 | 10 | 11 | Cc: 12 | 13 | ### What was Implemented 14 | 15 | 16 | ### Closes 17 | 18 | 19 | ### More Information 20 | 21 | 22 | ### Screenshots 23 | 24 | 25 | Thanks! 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/1-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug Fix" 3 | about: "Fixing a bug" 4 | title: "" 5 | labels: "Type: Bug" 6 | assignees: "" 7 | 8 | --- 9 | 10 | 11 | Cc: 12 | 13 | ### General summary of fix 14 | 15 | 16 | ### Closes 17 | 18 | 19 | ### More Information 20 | 21 | 22 | ### Screenshots 23 | 24 | 25 | Thanks! 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/2-documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Documentation" 3 | about: "Updating documentation" 4 | title: "" 5 | labels: "Type: Documentation" 6 | assignees: "" 7 | 8 | --- 9 | 10 | 11 | Cc: 12 | 13 | ### What did you add? 14 | 15 | 16 | ### Closes 17 | 18 | 19 | ### More Information 20 | 21 | 22 | Thanks! 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/4-refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Refactor" 3 | about: "Something you improving whithout changing the original functionality" 4 | title: "" 5 | labels: "Type: Refactor" 6 | assignees: "" 7 | 8 | --- 9 | 10 | 11 | Cc: 12 | 13 | ### What was Refactored? 14 | 15 | 16 | ### Closes 17 | 18 | 19 | ### More Information 20 | 21 | 22 | Thanks! 23 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write # To push a branch 12 | pull-requests: write # To create a PR from that branch 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | - name: Install mdbook 18 | run: | 19 | mkdir mdbook 20 | curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.27/mdbook-v0.4.27-x86_64-unknown-linux-gnu.tar.gz | tar -xz --directory=./mdbook 21 | echo `pwd`/mdbook >> $GITHUB_PATH 22 | - name: Deploy GitHub Pages 23 | run: | 24 | cd book 25 | mdbook build 26 | git worktree add gh-pages 27 | git config user.name "Deploy from CI" 28 | git config user.email "" 29 | cd gh-pages 30 | # Delete the ref to avoid keeping history. 31 | git update-ref -d refs/heads/gh-pages 32 | rm -rf * 33 | mv ../book/* . 34 | git add . 35 | git commit -m "Deploy $GITHUB_SHA to gh-pages" 36 | git push --force --set-upstream origin gh-pages 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | tags.lock 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.20" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "autocfg" 16 | version = "1.1.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 19 | 20 | [[package]] 21 | name = "bitflags" 22 | version = "1.3.2" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 25 | 26 | [[package]] 27 | name = "cassowary" 28 | version = "0.3.0" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 31 | 32 | [[package]] 33 | name = "cc" 34 | version = "1.0.79" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" 37 | 38 | [[package]] 39 | name = "cfg-if" 40 | version = "1.0.0" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 43 | 44 | [[package]] 45 | name = "clap" 46 | version = "4.1.6" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "ec0b0588d44d4d63a87dbd75c136c166bbfd9a86a31cb89e09906521c7d3f5e3" 49 | dependencies = [ 50 | "bitflags", 51 | "clap_derive", 52 | "clap_lex", 53 | "is-terminal", 54 | "once_cell", 55 | "strsim", 56 | "termcolor", 57 | ] 58 | 59 | [[package]] 60 | name = "clap_derive" 61 | version = "4.1.0" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "684a277d672e91966334af371f1a7b5833f9aa00b07c84e92fbce95e00208ce8" 64 | dependencies = [ 65 | "heck", 66 | "proc-macro-error", 67 | "proc-macro2", 68 | "quote", 69 | "syn", 70 | ] 71 | 72 | [[package]] 73 | name = "clap_lex" 74 | version = "0.3.2" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "350b9cf31731f9957399229e9b2adc51eeabdfbe9d71d9a0552275fd12710d09" 77 | dependencies = [ 78 | "os_str_bytes", 79 | ] 80 | 81 | [[package]] 82 | name = "crossterm" 83 | version = "0.25.0" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" 86 | dependencies = [ 87 | "bitflags", 88 | "crossterm_winapi", 89 | "libc", 90 | "mio", 91 | "parking_lot", 92 | "signal-hook", 93 | "signal-hook-mio", 94 | "winapi", 95 | ] 96 | 97 | [[package]] 98 | name = "crossterm" 99 | version = "0.26.0" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "77f67c7faacd4db07a939f55d66a983a5355358a1f17d32cc9a8d01d1266b9ce" 102 | dependencies = [ 103 | "bitflags", 104 | "crossterm_winapi", 105 | "libc", 106 | "mio", 107 | "parking_lot", 108 | "signal-hook", 109 | "signal-hook-mio", 110 | "winapi", 111 | ] 112 | 113 | [[package]] 114 | name = "crossterm_winapi" 115 | version = "0.9.0" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" 118 | dependencies = [ 119 | "winapi", 120 | ] 121 | 122 | [[package]] 123 | name = "errno" 124 | version = "0.2.8" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" 127 | dependencies = [ 128 | "errno-dragonfly", 129 | "libc", 130 | "winapi", 131 | ] 132 | 133 | [[package]] 134 | name = "errno-dragonfly" 135 | version = "0.1.2" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" 138 | dependencies = [ 139 | "cc", 140 | "libc", 141 | ] 142 | 143 | [[package]] 144 | name = "getrandom" 145 | version = "0.2.8" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" 148 | dependencies = [ 149 | "cfg-if", 150 | "libc", 151 | "wasi", 152 | ] 153 | 154 | [[package]] 155 | name = "heck" 156 | version = "0.4.1" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 159 | 160 | [[package]] 161 | name = "hermit-abi" 162 | version = "0.3.1" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" 165 | 166 | [[package]] 167 | name = "io-lifetimes" 168 | version = "1.0.5" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "1abeb7a0dd0f8181267ff8adc397075586500b81b28a73e8a0208b00fc170fb3" 171 | dependencies = [ 172 | "libc", 173 | "windows-sys 0.45.0", 174 | ] 175 | 176 | [[package]] 177 | name = "is-terminal" 178 | version = "0.4.4" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "21b6b32576413a8e69b90e952e4a026476040d81017b80445deda5f2d3921857" 181 | dependencies = [ 182 | "hermit-abi", 183 | "io-lifetimes", 184 | "rustix", 185 | "windows-sys 0.45.0", 186 | ] 187 | 188 | [[package]] 189 | name = "libc" 190 | version = "0.2.139" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" 193 | 194 | [[package]] 195 | name = "linux-raw-sys" 196 | version = "0.1.4" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" 199 | 200 | [[package]] 201 | name = "lock_api" 202 | version = "0.4.9" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" 205 | dependencies = [ 206 | "autocfg", 207 | "scopeguard", 208 | ] 209 | 210 | [[package]] 211 | name = "log" 212 | version = "0.4.17" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 215 | dependencies = [ 216 | "cfg-if", 217 | ] 218 | 219 | [[package]] 220 | name = "memchr" 221 | version = "2.5.0" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 224 | 225 | [[package]] 226 | name = "mio" 227 | version = "0.8.5" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" 230 | dependencies = [ 231 | "libc", 232 | "log", 233 | "wasi", 234 | "windows-sys 0.42.0", 235 | ] 236 | 237 | [[package]] 238 | name = "once_cell" 239 | version = "1.17.1" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" 242 | 243 | [[package]] 244 | name = "os_str_bytes" 245 | version = "6.4.1" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "9b7820b9daea5457c9f21c69448905d723fbd21136ccf521748f23fd49e723ee" 248 | 249 | [[package]] 250 | name = "oxycards" 251 | version = "1.0.2" 252 | dependencies = [ 253 | "clap", 254 | "crossterm 0.26.0", 255 | "rand", 256 | "regex", 257 | "tui", 258 | ] 259 | 260 | [[package]] 261 | name = "parking_lot" 262 | version = "0.12.1" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" 265 | dependencies = [ 266 | "lock_api", 267 | "parking_lot_core", 268 | ] 269 | 270 | [[package]] 271 | name = "parking_lot_core" 272 | version = "0.9.6" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "ba1ef8814b5c993410bb3adfad7a5ed269563e4a2f90c41f5d85be7fb47133bf" 275 | dependencies = [ 276 | "cfg-if", 277 | "libc", 278 | "redox_syscall", 279 | "smallvec", 280 | "windows-sys 0.42.0", 281 | ] 282 | 283 | [[package]] 284 | name = "ppv-lite86" 285 | version = "0.2.17" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" 288 | 289 | [[package]] 290 | name = "proc-macro-error" 291 | version = "1.0.4" 292 | source = "registry+https://github.com/rust-lang/crates.io-index" 293 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 294 | dependencies = [ 295 | "proc-macro-error-attr", 296 | "proc-macro2", 297 | "quote", 298 | "syn", 299 | "version_check", 300 | ] 301 | 302 | [[package]] 303 | name = "proc-macro-error-attr" 304 | version = "1.0.4" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 307 | dependencies = [ 308 | "proc-macro2", 309 | "quote", 310 | "version_check", 311 | ] 312 | 313 | [[package]] 314 | name = "proc-macro2" 315 | version = "1.0.51" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" 318 | dependencies = [ 319 | "unicode-ident", 320 | ] 321 | 322 | [[package]] 323 | name = "quote" 324 | version = "1.0.23" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" 327 | dependencies = [ 328 | "proc-macro2", 329 | ] 330 | 331 | [[package]] 332 | name = "rand" 333 | version = "0.8.5" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 336 | dependencies = [ 337 | "libc", 338 | "rand_chacha", 339 | "rand_core", 340 | ] 341 | 342 | [[package]] 343 | name = "rand_chacha" 344 | version = "0.3.1" 345 | source = "registry+https://github.com/rust-lang/crates.io-index" 346 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 347 | dependencies = [ 348 | "ppv-lite86", 349 | "rand_core", 350 | ] 351 | 352 | [[package]] 353 | name = "rand_core" 354 | version = "0.6.4" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 357 | dependencies = [ 358 | "getrandom", 359 | ] 360 | 361 | [[package]] 362 | name = "redox_syscall" 363 | version = "0.2.16" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" 366 | dependencies = [ 367 | "bitflags", 368 | ] 369 | 370 | [[package]] 371 | name = "regex" 372 | version = "1.7.1" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" 375 | dependencies = [ 376 | "aho-corasick", 377 | "memchr", 378 | "regex-syntax", 379 | ] 380 | 381 | [[package]] 382 | name = "regex-syntax" 383 | version = "0.6.28" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" 386 | 387 | [[package]] 388 | name = "rustix" 389 | version = "0.36.8" 390 | source = "registry+https://github.com/rust-lang/crates.io-index" 391 | checksum = "f43abb88211988493c1abb44a70efa56ff0ce98f233b7b276146f1f3f7ba9644" 392 | dependencies = [ 393 | "bitflags", 394 | "errno", 395 | "io-lifetimes", 396 | "libc", 397 | "linux-raw-sys", 398 | "windows-sys 0.45.0", 399 | ] 400 | 401 | [[package]] 402 | name = "scopeguard" 403 | version = "1.1.0" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 406 | 407 | [[package]] 408 | name = "signal-hook" 409 | version = "0.3.14" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" 412 | dependencies = [ 413 | "libc", 414 | "signal-hook-registry", 415 | ] 416 | 417 | [[package]] 418 | name = "signal-hook-mio" 419 | version = "0.2.3" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" 422 | dependencies = [ 423 | "libc", 424 | "mio", 425 | "signal-hook", 426 | ] 427 | 428 | [[package]] 429 | name = "signal-hook-registry" 430 | version = "1.4.0" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" 433 | dependencies = [ 434 | "libc", 435 | ] 436 | 437 | [[package]] 438 | name = "smallvec" 439 | version = "1.10.0" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" 442 | 443 | [[package]] 444 | name = "strsim" 445 | version = "0.10.0" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 448 | 449 | [[package]] 450 | name = "syn" 451 | version = "1.0.108" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "d56e159d99e6c2b93995d171050271edb50ecc5288fbc7cc17de8fdce4e58c14" 454 | dependencies = [ 455 | "proc-macro2", 456 | "quote", 457 | "unicode-ident", 458 | ] 459 | 460 | [[package]] 461 | name = "termcolor" 462 | version = "1.2.0" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" 465 | dependencies = [ 466 | "winapi-util", 467 | ] 468 | 469 | [[package]] 470 | name = "tui" 471 | version = "0.19.0" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1" 474 | dependencies = [ 475 | "bitflags", 476 | "cassowary", 477 | "crossterm 0.25.0", 478 | "unicode-segmentation", 479 | "unicode-width", 480 | ] 481 | 482 | [[package]] 483 | name = "unicode-ident" 484 | version = "1.0.6" 485 | source = "registry+https://github.com/rust-lang/crates.io-index" 486 | checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" 487 | 488 | [[package]] 489 | name = "unicode-segmentation" 490 | version = "1.10.0" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" 493 | 494 | [[package]] 495 | name = "unicode-width" 496 | version = "0.1.10" 497 | source = "registry+https://github.com/rust-lang/crates.io-index" 498 | checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" 499 | 500 | [[package]] 501 | name = "version_check" 502 | version = "0.9.4" 503 | source = "registry+https://github.com/rust-lang/crates.io-index" 504 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 505 | 506 | [[package]] 507 | name = "wasi" 508 | version = "0.11.0+wasi-snapshot-preview1" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 511 | 512 | [[package]] 513 | name = "winapi" 514 | version = "0.3.9" 515 | source = "registry+https://github.com/rust-lang/crates.io-index" 516 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 517 | dependencies = [ 518 | "winapi-i686-pc-windows-gnu", 519 | "winapi-x86_64-pc-windows-gnu", 520 | ] 521 | 522 | [[package]] 523 | name = "winapi-i686-pc-windows-gnu" 524 | version = "0.4.0" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 527 | 528 | [[package]] 529 | name = "winapi-util" 530 | version = "0.1.5" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 533 | dependencies = [ 534 | "winapi", 535 | ] 536 | 537 | [[package]] 538 | name = "winapi-x86_64-pc-windows-gnu" 539 | version = "0.4.0" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 542 | 543 | [[package]] 544 | name = "windows-sys" 545 | version = "0.42.0" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" 548 | dependencies = [ 549 | "windows_aarch64_gnullvm", 550 | "windows_aarch64_msvc", 551 | "windows_i686_gnu", 552 | "windows_i686_msvc", 553 | "windows_x86_64_gnu", 554 | "windows_x86_64_gnullvm", 555 | "windows_x86_64_msvc", 556 | ] 557 | 558 | [[package]] 559 | name = "windows-sys" 560 | version = "0.45.0" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 563 | dependencies = [ 564 | "windows-targets", 565 | ] 566 | 567 | [[package]] 568 | name = "windows-targets" 569 | version = "0.42.1" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" 572 | dependencies = [ 573 | "windows_aarch64_gnullvm", 574 | "windows_aarch64_msvc", 575 | "windows_i686_gnu", 576 | "windows_i686_msvc", 577 | "windows_x86_64_gnu", 578 | "windows_x86_64_gnullvm", 579 | "windows_x86_64_msvc", 580 | ] 581 | 582 | [[package]] 583 | name = "windows_aarch64_gnullvm" 584 | version = "0.42.1" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" 587 | 588 | [[package]] 589 | name = "windows_aarch64_msvc" 590 | version = "0.42.1" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" 593 | 594 | [[package]] 595 | name = "windows_i686_gnu" 596 | version = "0.42.1" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" 599 | 600 | [[package]] 601 | name = "windows_i686_msvc" 602 | version = "0.42.1" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" 605 | 606 | [[package]] 607 | name = "windows_x86_64_gnu" 608 | version = "0.42.1" 609 | source = "registry+https://github.com/rust-lang/crates.io-index" 610 | checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" 611 | 612 | [[package]] 613 | name = "windows_x86_64_gnullvm" 614 | version = "0.42.1" 615 | source = "registry+https://github.com/rust-lang/crates.io-index" 616 | checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" 617 | 618 | [[package]] 619 | name = "windows_x86_64_msvc" 620 | version = "0.42.1" 621 | source = "registry+https://github.com/rust-lang/crates.io-index" 622 | checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" 623 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "oxycards" 3 | version = "1.0.2" 4 | edition = "2021" 5 | authors = ["Brook Jeynes jeynesbrook@pm.me", "twny hitawnie@gmail.com"] 6 | license = "MIT" 7 | description = "An interactive quiz application built for the terminal." 8 | documentation = "https://brookjeynes.github.io/oxycards/" 9 | readme = "README.md" 10 | homepage = "https://github.com/BrookJeynes/oxycards" 11 | repository = "https://github.com/BrookJeynes/oxycards-rs" 12 | keywords = ["cli", "tui", "quiz", "study", "flashcard"] 13 | categories = ["command-line-utilities"] 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [dependencies] 18 | regex = "1.7.1" # An implementation of regular expressions for Rust. This implementation uses finite automata … 19 | rand = "0.8.5" # Random number generators and other randomness functionality. 20 | tui = "0.19.0" # A library to build rich terminal user interfaces or dashboards 21 | crossterm = "0.26.0" # A crossplatform terminal library for manipulating terminals. 22 | clap = { version = "4.1.6", features = ["derive"] } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Brook Jeynes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oxycards - Terminal based quiz cards 2 | 3 | Oxycards is a quiz card application built within the terminal. 4 | 5 | 6 | ## Screenshots 7 | 8 | ![App demo](https://user-images.githubusercontent.com/25432120/222114920-a4fe8ade-7eb5-4aa2-8732-dc6675b7526b.gif) 9 | 10 | 11 | ## Features 12 | 13 | - Create quiz cards in markdown 14 | - Variety of card types 15 | - Multiple choice 16 | - Multiple answer 17 | - Flashcard 18 | - Fill in the blanks 19 | - Place in the correct order 20 | 21 | 22 | ## Links 23 | - [Documentation](https://brookjeynes.github.io/oxycards/) 24 | - [Installation](https://brookjeynes.github.io/oxycards/installation) 25 | 26 | 27 | ## Roadmap 28 | - [x] Complete all basic card types 29 | - [x] Multiple choice 30 | - [x] Multiple answer 31 | - [x] Flashcard 32 | - [x] Fill in the blanks 33 | - [x] Place in the correct order 34 | - [x] Read in card input file from command line 35 | - [x] Release on distribution platforms for easy install 36 | - [ ] Allow users to change certain settings via a config file 37 | - [ ] Add spaced repetition 38 | - [ ] Website for users to share their decks 39 | 40 | 41 | #### Attributions 42 | This application was heavily inspired by [hascard](https://github.com/Yvee1/hascard). 43 | -------------------------------------------------------------------------------- /book/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /book/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Brook Jeynes"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "Documentation - Oxycards" 7 | -------------------------------------------------------------------------------- /book/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](./introduction.md) 4 | 5 | # User Guide 6 | - [Getting started](./getting-started.md) 7 | - [Installation](./installation.md) 8 | - [Configuration]() 9 | - [Cards](./cards.md) 10 | - [Flashcard](./flashcard.md) 11 | - [Multiple Choice](./multiple_choice.md) 12 | - [Multiple Answer](./multiple_answer.md) 13 | - [Fill in the Blanks](./fill_in_the_blanks.md) 14 | - [Order](./order.md) 15 | -------------------------------------------------------------------------------- /book/src/cards.md: -------------------------------------------------------------------------------- 1 | # Cards 2 | 3 | ## Global Formatting 4 | 5 | Cards must be prefixed with their type. Here is a list of all available card types: 6 | - flashcard 7 | - multiple_choice 8 | - multiple_answer 9 | - fill_in_the_blanks 10 | - order 11 | 12 | When creating more than one card, they must be seperated by a triple dash (`---`). 13 | 14 | ## Example 15 | 16 | ```md 17 | flashcard 18 | 19 | # Word or question 20 | Explanation or definition of this word, or the answer to the question. 21 | 22 | --- 23 | 24 | multiple_choice 25 | 26 | # Multiple choice question - (correct answer is denoted by an *) 27 | * Choice 1 28 | - Choice 2 29 | - Choice 3 30 | - Choice 4 31 | 32 | --- 33 | 34 | multiple_answer 35 | 36 | # Multiple answer question - (correct answers is denoted by an *) 37 | [*] Option 1 38 | [ ] Option 2 39 | [ ] Option 3 40 | [*] Option 4 41 | 42 | --- 43 | 44 | fill_in_the_blanks 45 | 46 | # Fill in the gaps 47 | The word chook, also know as _chicken_, is a word commonly used in _AUS|Australia_. 48 | --- 49 | 50 | order 51 | 52 | # Order the numbers from largest to smallest - (options are placed in the correct ordering and are shuffed within the application) 53 | 1. 100000 54 | 2. 4235 55 | 3. 23 56 | 4. 6 57 | ``` 58 | 59 | ## Global Controls 60 | 61 | | Key | Description | 62 | |---------|------------------| 63 | | q | Quit Application | 64 | | \ | Validate Answer | 65 | -------------------------------------------------------------------------------- /book/src/configuration.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrookJeynes/oxycards/e56b821059256bcafd6149ae96528db7a5c3af22/book/src/configuration.md -------------------------------------------------------------------------------- /book/src/fill_in_the_blanks.md: -------------------------------------------------------------------------------- 1 | # Fill in the Blanks 2 | 3 | ![Fill in the Blanks example](https://user-images.githubusercontent.com/25432120/222114868-e6f4f6d5-fcc9-40df-a7c9-41a48b01df0b.gif) 4 | 5 | Fill in the blank cards allow users to enter their own answers within a series 6 | of defined blank spaces. 7 | 8 | ## Formatting 9 | Answers are surrounded by the underscore character (`_`), e.g. \_chicken\_ 10 | 11 | Multiple answers for a blank space can be defined via the pipe character (`|`), e.g. \_AUS|Australia\_ 12 | 13 | ## Example 14 | 15 | ```md 16 | fill_in_the_blanks 17 | 18 | # Fill in the gaps 19 | The word chook, also know as _chicken_, is a word commonly used in _AUS|Australia_. 20 | ``` 21 | 22 | ## Controls 23 | 24 | | Key | Description | 25 | |--------|-----------------| 26 | | \ | Cycle selection | 27 | -------------------------------------------------------------------------------- /book/src/flashcard.md: -------------------------------------------------------------------------------- 1 | # Flashcards 2 | 3 | ![Flashcard Example](https://user-images.githubusercontent.com/25432120/222114908-261c3888-254f-4002-9508-4cb3d95eb7cf.gif) 4 | 5 | Flashcards are the most basic cards allowing for a simple front / back description. 6 | 7 | ## Formatting 8 | The answer for the card goes beneath the header, it is allowed to span multiple lines. 9 | 10 | ## Example 11 | 12 | ```md 13 | flashcard 14 | 15 | # Word or question 16 | Explanation or definition of this word, or the answer to the question. 17 | ``` 18 | 19 | ## Controls 20 | 21 | | Key | Description | 22 | |---------|----------------| 23 | | \ | Flip card over | 24 | -------------------------------------------------------------------------------- /book/src/getting-started.md: -------------------------------------------------------------------------------- 1 | # Installing and Configuring the Application 2 | 3 | The how-to of getting this application installed, configured, and running. 4 | - [Installation](./installation.md) 5 | - [Configuration]() 6 | -------------------------------------------------------------------------------- /book/src/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## General Installation 4 | After ensuring you have Rust installed, simply run the following command to install oxycards: 5 | ```bash 6 | cargo install oxycards 7 | ``` 8 | 9 | To uninstall, simply run the following command: 10 | ```bash 11 | cargo uninstall oxycards 12 | ``` 13 | 14 | ## Building Locally 15 | 16 | 1. Clone the project 17 | 18 | ```bash 19 | git clone https://github.com/BrookJeynes/oxycards 20 | ``` 21 | 22 | 2. Go to the project directory 23 | 24 | ```bash 25 | cd oxycards 26 | ``` 27 | 28 | 3. Create a set of cards within `input.md` at the projects root. See the [Cards](./cards.md) section for more information about card formatting. 29 | 30 | 4. Start the application 31 | 32 | ```bash 33 | cargo run 34 | ``` 35 | -------------------------------------------------------------------------------- /book/src/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Oxycards is a quiz card application built within the terminal. 4 | 5 | ![Application demo](https://user-images.githubusercontent.com/25432120/222114920-a4fe8ade-7eb5-4aa2-8732-dc6675b7526b.gif) 6 | -------------------------------------------------------------------------------- /book/src/multiple_answer.md: -------------------------------------------------------------------------------- 1 | # Multiple Answer 2 | 3 | ![Multiple Answer Example](https://user-images.githubusercontent.com/25432120/222114909-47c9340b-8571-49e7-a903-649ccc18b932.gif) 4 | 5 | Multiple answer cards allow the user to select any given amount of answers given a 6 | series of options. 7 | 8 | ## Formatting 9 | 10 | Correct answers are prefixed with `[*]` 11 | 12 | Incorrect answers are prefixed with `[ ]` 13 | 14 | ## Example 15 | 16 | ```md 17 | multiple_answer 18 | 19 | # Multiple answer question 20 | [*] Option 1 21 | [ ] Option 2 22 | [ ] Option 3 23 | [*] Option 4 24 | ``` 25 | 26 | ## Controls 27 | 28 | | Key | Description | 29 | |---------|----------------| 30 | | \ | Select options | 31 | -------------------------------------------------------------------------------- /book/src/multiple_choice.md: -------------------------------------------------------------------------------- 1 | # Multiple Choice 2 | 3 | ![Multiple Choice Example](https://user-images.githubusercontent.com/25432120/222114930-de1395f9-4f12-4b54-86f2-49677316be13.gif) 4 | 5 | Multiple choice cards allow the user to select a single answer given a series 6 | of options. 7 | 8 | ## Formatting 9 | 10 | Correct answers are prefixed with the star character (`*`) 11 | 12 | Incorrect answers are prefixed with the dash character (`-`) 13 | 14 | ## Example 15 | 16 | ```md 17 | multiple_choice 18 | 19 | # Multiple choice question 20 | * Choice 1 21 | - Choice 2 22 | - Choice 3 23 | - Choice 4 24 | ``` 25 | 26 | ## Controls 27 | 28 | | Key | Description | 29 | |---------|----------------| 30 | | \ | Select option | 31 | -------------------------------------------------------------------------------- /book/src/order.md: -------------------------------------------------------------------------------- 1 | # Order 2 | 3 | ![Order Example](https://user-images.githubusercontent.com/25432120/222114913-9e3827af-7293-4d5d-8a1c-8c8b7f6fe232.gif) 4 | 5 | Order cards give the user a randomly sorted list of options in which they must 6 | re-sort them into the correct ordering. 7 | 8 | ## Formatting 9 | 10 | Options are written in the correct order and are shuffled within the application 11 | 12 | ## Example 13 | 14 | ```md 15 | order 16 | 17 | # Order the numbers from largest to smallest 18 | 1. 100000 19 | 2. 4235 20 | 3. 23 21 | 4. 6 22 | ``` 23 | 24 | ## Controls 25 | 26 | | Key | Description | 27 | |---------|---------------------| 28 | | \ | Select item, use \ again on another item to swap them | 29 | -------------------------------------------------------------------------------- /example.md: -------------------------------------------------------------------------------- 1 | flashcard 2 | 3 | # Word or question 4 | Explanation or definition of this word, or the answer to the question. 5 | 6 | --- 7 | 8 | multiple_choice 9 | 10 | # Multiple choice question, (only one answer is right) 11 | - Choice 1 12 | * Choice 2 (this is the correct answer) 13 | - Choice 3 14 | - Choice 4 15 | 16 | --- 17 | 18 | multiple_answer 19 | 20 | # Multiple answer question 21 | [*] Option 1 (this is a correct answer) 22 | [ ] Option 2 23 | [*] Option 3 (this is a correct answer) 24 | [ ] Option 4 25 | 26 | --- 27 | 28 | fill_in_the_blanks 29 | 30 | # Fill in the gaps 31 | The word chook, also know as _chicken_, is a word commonly used in _AUS|Australia_. 32 | 33 | --- 34 | 35 | order 36 | 37 | # Order the letters in alphabetical order 38 | 1. l 39 | 2. p 40 | 3. s 41 | 4. u 42 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod models; 2 | pub mod ui; 3 | 4 | use clap::Parser; 5 | use models::args::Args; 6 | use models::errors::errors::Errors; 7 | 8 | use std::path::Path; 9 | use std::{error::Error, fs, io}; 10 | 11 | use crossterm::event::{self, Event, KeyCode}; 12 | use crossterm::execute; 13 | use crossterm::terminal::{ 14 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 15 | }; 16 | 17 | use models::card::Card; 18 | use models::stateful_list::StatefulList; 19 | use models::user_answer::UserAnswer; 20 | 21 | use tui::backend::{Backend, CrosstermBackend}; 22 | use tui::Terminal; 23 | use ui::ui; 24 | 25 | pub enum InputMode { 26 | Normal, 27 | Editing, 28 | } 29 | 30 | pub struct Score { 31 | incorrect: usize, 32 | correct: usize, 33 | } 34 | 35 | impl Score { 36 | fn add_incorrect(&mut self) { 37 | self.incorrect += 1; 38 | } 39 | 40 | fn add_correct(&mut self) { 41 | self.correct += 1; 42 | } 43 | } 44 | 45 | impl Default for Score { 46 | fn default() -> Self { 47 | Self { 48 | incorrect: 0, 49 | correct: 0, 50 | } 51 | } 52 | } 53 | 54 | pub struct AppState { 55 | pub cards: StatefulList, 56 | pub input_mode: InputMode, 57 | pub score: Score, 58 | } 59 | 60 | impl AppState { 61 | fn new(cards: Vec) -> Self { 62 | Self { 63 | cards: StatefulList::with_items(cards), 64 | score: Score::default(), 65 | input_mode: InputMode::Normal, 66 | } 67 | } 68 | } 69 | 70 | fn read_from_file(path: &Path) -> Result { 71 | fs::read_to_string(path) 72 | } 73 | 74 | fn main() -> Result<(), Box> { 75 | let args = Args::parse(); 76 | let path = Path::new(&args.path); 77 | 78 | if let Err(err) = Args::validate_file(path) { 79 | Errors::throw_file_error(err) 80 | }; 81 | 82 | let content = read_from_file(path)?; 83 | let cards = match Card::card_parser(content) { 84 | Ok(cards) => cards, 85 | Err(err) => Errors::throw_parsing_error(err), 86 | }; 87 | 88 | let mut terminal = init_terminal()?; 89 | 90 | let app_state = AppState::new(cards); 91 | let res = run_app(&mut terminal, app_state); 92 | 93 | reset_terminal()?; 94 | 95 | if let Err(err) = res { 96 | println!("{:?}", err); 97 | } 98 | 99 | Ok(()) 100 | } 101 | 102 | /// Initializes the terminal. 103 | fn init_terminal() -> Result>, Box> { 104 | execute!(io::stdout(), EnterAlternateScreen)?; 105 | enable_raw_mode()?; 106 | 107 | let backend = CrosstermBackend::new(io::stdout()); 108 | 109 | let mut terminal = Terminal::new(backend)?; 110 | terminal.hide_cursor()?; 111 | 112 | // Because a panic interrupts the normal control flow, manually resetting the 113 | // terminal at the end of `main` won't do us any good. Instead, we need to 114 | // make sure to set up a panic hook that first resets the terminal before 115 | // handling the panic. This both reuses the standard panic hook to ensure a 116 | // consistent panic handling UX and properly resets the terminal to not 117 | // distort the output. 118 | let original_hook = std::panic::take_hook(); 119 | 120 | std::panic::set_hook(Box::new(move |panic| { 121 | reset_terminal().unwrap(); 122 | original_hook(panic); 123 | })); 124 | 125 | Ok(terminal) 126 | } 127 | 128 | /// Resets the terminal. 129 | fn reset_terminal() -> Result<(), Box> { 130 | disable_raw_mode()?; 131 | crossterm::execute!(io::stdout(), LeaveAlternateScreen)?; 132 | 133 | Ok(()) 134 | } 135 | 136 | fn run_app( 137 | terminal: &mut Terminal, 138 | mut app_state: AppState, 139 | ) -> Result<(), Box> { 140 | loop { 141 | terminal.draw(|f| ui(f, &mut app_state))?; 142 | 143 | if let Some(val) = app_state.cards.selected_value() { 144 | match val { 145 | Card::FillInTheBlanks(card) => { 146 | if let UserAnswer::Undecided = card.user_answer { 147 | app_state.input_mode = InputMode::Editing 148 | } 149 | } 150 | _ => app_state.input_mode = InputMode::Normal, 151 | } 152 | } 153 | 154 | if let Event::Key(key) = event::read()? { 155 | match app_state.input_mode { 156 | InputMode::Normal => match key.code { 157 | // Card navigation keys 158 | KeyCode::Char('h') | KeyCode::Left => app_state.cards.previous(), 159 | KeyCode::Char('l') | KeyCode::Right => app_state.cards.next(), 160 | 161 | KeyCode::Char(' ') => { 162 | if let Some(val) = app_state.cards.selected_value() { 163 | match val { 164 | Card::FlashCard(card) => card.show_back(), 165 | Card::MultipleAnswer(card) => { 166 | if let UserAnswer::Undecided = card.user_answer { 167 | if let Some(index) = card.choices.selected() { 168 | card.choices.items[index].select() 169 | } 170 | } 171 | } 172 | Card::MultipleChoice(card) => { 173 | if let Some(index) = card.choices.selected() { 174 | if let UserAnswer::Undecided = card.user_answer { 175 | card.unselect_all(); 176 | 177 | card.choices.items[index].select() 178 | } 179 | } 180 | } 181 | Card::Order(card) => { 182 | if let UserAnswer::Undecided = card.user_answer { 183 | if let Some(index) = card.shuffled.selected() { 184 | card.shuffled.items[index].select() 185 | } 186 | 187 | if let Some((a, b)) = card.multiple_selected() { 188 | card.shuffled.swap(a, b); 189 | card.unselect_all(); 190 | } 191 | } 192 | } 193 | _ => {} 194 | } 195 | } 196 | } 197 | 198 | KeyCode::Char('k') | KeyCode::Up => { 199 | if let Some(val) = app_state.cards.selected_value() { 200 | match val { 201 | Card::MultipleChoice(card) => card.choices.previous(), 202 | Card::MultipleAnswer(card) => card.choices.previous(), 203 | Card::Order(card) => card.shuffled.previous(), 204 | _ => {} 205 | } 206 | } 207 | } 208 | KeyCode::Char('j') | KeyCode::Down => { 209 | if let Some(val) = app_state.cards.selected_value() { 210 | match val { 211 | Card::MultipleChoice(card) => card.choices.next(), 212 | Card::MultipleAnswer(card) => card.choices.next(), 213 | Card::Order(card) => card.shuffled.next(), 214 | _ => {} 215 | } 216 | } 217 | } 218 | KeyCode::Enter => { 219 | if let Some(card) = app_state.cards.selected_value() { 220 | if !card.check_answered() { 221 | match card.validate_answer() { 222 | UserAnswer::Correct => app_state.score.add_correct(), 223 | UserAnswer::Incorrect => app_state.score.add_incorrect(), 224 | UserAnswer::Undecided => {} 225 | } 226 | } 227 | } 228 | } 229 | KeyCode::Char('y') => { 230 | if let Some(val) = app_state.cards.selected_value() { 231 | match val { 232 | Card::FlashCard(card) => { 233 | if card.show_validation_popup 234 | && card.user_answer == UserAnswer::Undecided 235 | { 236 | card.user_answer = UserAnswer::Correct; 237 | app_state.score.add_correct() 238 | } 239 | } 240 | 241 | _ => {} 242 | } 243 | } 244 | } 245 | KeyCode::Char('n') => { 246 | if let Some(val) = app_state.cards.selected_value() { 247 | match val { 248 | Card::FlashCard(card) => { 249 | if card.show_validation_popup 250 | && card.user_answer == UserAnswer::Undecided 251 | { 252 | card.user_answer = UserAnswer::Incorrect; 253 | app_state.score.add_incorrect() 254 | } 255 | } 256 | 257 | _ => {} 258 | } 259 | } 260 | } 261 | 262 | // Exit keys 263 | KeyCode::Char('q') => return Ok(()), 264 | 265 | _ => {} 266 | }, 267 | InputMode::Editing => match key.code { 268 | KeyCode::Tab => { 269 | if let Some(val) = app_state.cards.selected_value() { 270 | match val { 271 | Card::FillInTheBlanks(card) => { 272 | card.next(); 273 | } 274 | 275 | _ => {} 276 | } 277 | } 278 | } 279 | KeyCode::Enter => { 280 | if let Some(card) = app_state.cards.selected_value() { 281 | if !card.check_answered() { 282 | match card.validate_answer() { 283 | UserAnswer::Correct => app_state.score.add_correct(), 284 | UserAnswer::Incorrect => app_state.score.add_incorrect(), 285 | UserAnswer::Undecided => {} 286 | } 287 | 288 | app_state.input_mode = InputMode::Normal; 289 | } 290 | } 291 | } 292 | KeyCode::Char(c) => { 293 | if let Some(card_) = app_state.cards.selected_value() { 294 | if let Card::FillInTheBlanks(card) = card_ { 295 | card.user_input[card.blank_index].push(c); 296 | card.update_output(); 297 | } 298 | } 299 | } 300 | KeyCode::Backspace => { 301 | if let Some(card_) = app_state.cards.selected_value() { 302 | if let Card::FillInTheBlanks(card) = card_ { 303 | card.user_input[card.blank_index].pop(); 304 | card.update_output(); 305 | } 306 | } 307 | } 308 | KeyCode::Left => app_state.cards.previous(), 309 | KeyCode::Right => app_state.cards.next(), 310 | // Exit keys 311 | KeyCode::Esc => return Ok(()), 312 | _ => {} 313 | }, 314 | } 315 | } 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/models/args.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use clap::Parser; 4 | 5 | use super::{errors::file_error::FileError, file_type::FileType}; 6 | 7 | #[derive(Parser, Debug)] 8 | #[command(author, version, about)] 9 | pub struct Args { 10 | /// Path to a quiz md file 11 | #[arg(short, long)] 12 | pub path: String, 13 | } 14 | 15 | impl Args { 16 | pub fn validate_file(file: &Path) -> Result<(), FileError> { 17 | match file.extension() { 18 | Some(extension) => { 19 | if let Some(_) = FileType::from_osstr(extension) { 20 | return Ok(()); 21 | } 22 | } 23 | None => return Err(FileError::InvalidFileType), 24 | } 25 | 26 | Err(FileError::InvalidFileType) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/models/card.rs: -------------------------------------------------------------------------------- 1 | use crate::Errors; 2 | use core::fmt; 3 | 4 | use crate::UserAnswer; 5 | 6 | use super::{ 7 | card_types::{ 8 | fill_in_the_blanks::FillInTheBlanks, flashcard::FlashCard, multiple_answer::MultipleAnswer, 9 | multiple_choice::MultipleChoice, order::Order, 10 | }, 11 | errors::parsing_error::ParsingError, 12 | }; 13 | 14 | pub enum Card { 15 | FlashCard(FlashCard), 16 | MultipleChoice(MultipleChoice), 17 | MultipleAnswer(MultipleAnswer), 18 | FillInTheBlanks(FillInTheBlanks), 19 | Order(Order), 20 | } 21 | 22 | macro_rules! impl_various { 23 | ($($card_variant:ident),*) => { 24 | impl Card { 25 | pub fn validate_answer(&mut self) -> UserAnswer { 26 | match self { 27 | $(Card::$card_variant(card) => card.validate_answer()),* 28 | } 29 | } 30 | 31 | pub fn check_answered(&mut self) -> bool { 32 | match self { 33 | $(Card::$card_variant(card) => card.user_answer != UserAnswer::Undecided),* 34 | } 35 | } 36 | 37 | pub fn instructions(&self) -> String { 38 | match self { 39 | $(Card::$card_variant(card) => card.instructions()),* 40 | } 41 | } 42 | } 43 | 44 | impl fmt::Display for Card { 45 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 46 | match self { 47 | $(Card::$card_variant(card) => write!(f, "{card}")),* 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | impl_various!( 55 | FlashCard, 56 | MultipleAnswer, 57 | MultipleChoice, 58 | FillInTheBlanks, 59 | Order 60 | ); 61 | 62 | macro_rules! parse_cards { 63 | ($(($card_variant:ident, $card_type:expr)),*) => { 64 | impl Card { 65 | pub fn card_parser(content: String) -> Result, ParsingError> { 66 | let cards: Vec = content 67 | .split("---") 68 | .map(|section| { 69 | let sections = section 70 | .trim() 71 | .split("\n\n") 72 | .filter(|item| !item.is_empty()) 73 | .collect::>(); 74 | 75 | if sections.is_empty() { 76 | Errors::throw_parsing_error(ParsingError::IncorrectDivider) 77 | } 78 | 79 | match sections[0].to_lowercase().as_str() { 80 | $($card_type => Card::$card_variant( 81 | $card_variant::parse_raw(sections[1].to_string()).unwrap_or_else( 82 | |err| { 83 | Errors::throw_parsing_error(err) 84 | }, 85 | ), 86 | )),*, 87 | _ => { 88 | Errors::throw_parsing_error(ParsingError::NoCardType) 89 | } 90 | } 91 | }) 92 | .collect(); 93 | 94 | Ok(cards) 95 | } 96 | } 97 | }; 98 | } 99 | 100 | parse_cards!( 101 | (FlashCard, "flashcard"), 102 | (MultipleAnswer, "multiple_answer"), 103 | (MultipleChoice, "multiple_choice"), 104 | (FillInTheBlanks, "fill_in_the_blanks"), 105 | (Order, "order") 106 | ); 107 | 108 | impl Card { 109 | pub fn extract_card_title(content: &String) -> Result<(String, String), ParsingError> { 110 | let question = match content.lines().nth(0) { 111 | Some(val) => { 112 | if val.is_empty() { 113 | return Err(ParsingError::NoQuestion); 114 | } 115 | 116 | if val.chars().nth(0).unwrap_or(' ') != '#' { 117 | return Err(ParsingError::NoQuestion); 118 | } 119 | 120 | val[1..].trim().to_string() 121 | } 122 | None => return Err(ParsingError::NoQuestion), 123 | }; 124 | 125 | let content = content.lines().skip(1).collect::>().join("\n"); 126 | 127 | if content.is_empty() { 128 | return Err(ParsingError::NoContent); 129 | } 130 | 131 | Ok((question, content)) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/models/card_types/fill_in_the_blanks.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use regex::Regex; 3 | use std::collections::HashMap; 4 | 5 | use crate::{ 6 | models::{card::Card, errors::parsing_error::ParsingError}, 7 | UserAnswer, 8 | }; 9 | 10 | #[derive(Debug)] 11 | pub struct Answer { 12 | pub answers: Vec, 13 | pub content: String, 14 | } 15 | 16 | pub struct FillInTheBlanks { 17 | pub question: String, 18 | pub content: String, 19 | pub output: String, 20 | pub user_input: Vec, 21 | pub answers: HashMap>, 22 | pub blank_index: usize, 23 | pub user_answer: UserAnswer, 24 | } 25 | 26 | impl FillInTheBlanks { 27 | pub fn check_answered(&self) -> bool { 28 | false 29 | } 30 | 31 | pub fn parse_raw(content: String) -> Result { 32 | let (question, content) = Card::extract_card_title(&content)?; 33 | let re = Regex::new(r"_(.*?)_").expect("Error with regex string."); 34 | 35 | let answers = HashMap::from( 36 | re.captures_iter(content.as_ref()) 37 | .enumerate() 38 | .map(|(index, c)| { 39 | let capture: Vec = 40 | c[1].split("|").map(|item| item.to_string()).collect(); 41 | 42 | (index, capture) 43 | }) 44 | .collect::>>(), 45 | ); 46 | 47 | // Create an array with empty string of size answers 48 | let user_input: Vec = answers.iter().map(|_| String::new()).collect(); 49 | 50 | Ok(Self { 51 | question, 52 | content: re.replace_all(content.as_ref(), "__").to_string(), 53 | answers, 54 | output: re.replace_all(content.as_ref(), "_").to_string(), 55 | user_input, 56 | blank_index: 0, 57 | user_answer: UserAnswer::Undecided, 58 | }) 59 | } 60 | 61 | /// Move to the next fill-in-the-blank spot 62 | pub fn next(&mut self) { 63 | self.blank_index = (self.blank_index + 1) % self.answers.len(); 64 | } 65 | 66 | pub fn instructions(&self) -> String { 67 | String::from(": Quit application, : Cycle selection, : Add character pressed to blank space") 68 | } 69 | 70 | pub fn validate_answer(&mut self) -> UserAnswer { 71 | self.user_answer = UserAnswer::Correct; 72 | 73 | if self 74 | .user_input 75 | .iter() 76 | .filter(|item| !item.is_empty()) 77 | .collect::>() 78 | .is_empty() 79 | { 80 | self.user_answer = UserAnswer::Undecided; 81 | } 82 | 83 | for (index, item) in self.user_input.iter().enumerate() { 84 | if !self.answers.get(&index).unwrap_or(&vec![]).contains(item) && !item.is_empty() { 85 | self.user_answer = UserAnswer::Incorrect; 86 | } 87 | } 88 | 89 | self.user_answer 90 | } 91 | 92 | pub fn update_output(&mut self) { 93 | let new_content = self 94 | .content 95 | .split("__") 96 | .take(self.answers.len()) 97 | .enumerate() 98 | .map(|(index, item)| { 99 | format!( 100 | "{}{}_", 101 | item, 102 | self.user_input.get(index).unwrap_or(&String::new()) 103 | ) 104 | }) 105 | .collect::>() 106 | .join(""); 107 | 108 | self.output = new_content; 109 | } 110 | } 111 | 112 | impl fmt::Display for FillInTheBlanks { 113 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 114 | write!( 115 | f, 116 | "Question: {}\nContent: {:?}\nAnswers: {:?}", 117 | self.question, self.content, self.answers 118 | ) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/models/card_types/flashcard.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use crate::{models::errors::parsing_error::ParsingError, Card, UserAnswer}; 4 | 5 | pub struct FlashCard { 6 | pub question: String, 7 | pub answer: String, 8 | pub flipped: bool, 9 | pub show_validation_popup: bool, 10 | /// Has the card been validated/answered 11 | pub user_answer: UserAnswer, 12 | } 13 | 14 | impl FlashCard { 15 | pub fn instructions(&self) -> String { 16 | String::from(": Show cards back") 17 | } 18 | 19 | pub fn validate_answer(&mut self) -> UserAnswer { 20 | self.show_validation_popup = !self.show_validation_popup; 21 | 22 | UserAnswer::Undecided 23 | } 24 | 25 | pub fn parse_raw(content: String) -> Result { 26 | let (question, content) = Card::extract_card_title(&content)?; 27 | 28 | Ok(Self { 29 | question, 30 | answer: content, 31 | flipped: false, 32 | 33 | show_validation_popup: false, 34 | user_answer: UserAnswer::Undecided, 35 | }) 36 | } 37 | 38 | /// Flip card over to show the back. 39 | pub fn show_back(&mut self) { 40 | self.flipped = true; 41 | } 42 | 43 | /// Flip the card 44 | pub fn flip_card(&mut self) { 45 | self.flipped = !self.flipped; 46 | } 47 | } 48 | 49 | impl fmt::Display for FlashCard { 50 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 51 | write!(f, "Question: {}\nAnswer: {}", self.question, self.answer) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/models/card_types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod fill_in_the_blanks; 2 | pub mod flashcard; 3 | pub mod multiple_answer; 4 | pub mod multiple_choice; 5 | pub mod order; 6 | -------------------------------------------------------------------------------- /src/models/card_types/multiple_answer.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use crate::{ 4 | models::{choice::Choice, errors::parsing_error::ParsingError, stateful_list::StatefulList}, 5 | Card, UserAnswer, 6 | }; 7 | 8 | pub struct MultipleAnswer { 9 | pub question: String, 10 | pub choices: StatefulList, 11 | pub answers: Vec, 12 | pub user_answer: UserAnswer, 13 | } 14 | 15 | impl MultipleAnswer { 16 | pub fn instructions(&self) -> String { 17 | String::from(": Select/unselect choice") 18 | } 19 | 20 | pub fn validate_answer(&mut self) -> UserAnswer { 21 | let choices = self 22 | .choices 23 | .items 24 | .iter() 25 | .filter(|item| item.selected) 26 | .map(|item| item.content.to_string()) 27 | .collect::>(); 28 | 29 | self.user_answer = if choices.is_empty() { 30 | UserAnswer::Undecided 31 | } else if choices == self.answers { 32 | UserAnswer::Correct 33 | } else { 34 | UserAnswer::Incorrect 35 | }; 36 | 37 | self.user_answer 38 | } 39 | 40 | pub fn parse_raw(content: String) -> Result { 41 | let (question, content) = Card::extract_card_title(&content)?; 42 | 43 | Ok(Self { 44 | question, 45 | choices: StatefulList::with_items( 46 | MultipleAnswer::remove_prefix(vec![' ', '*'], &content) 47 | .iter() 48 | .map(|choice| Choice { 49 | content: choice.clone(), 50 | selected: false, 51 | }) 52 | .collect(), 53 | ), 54 | answers: MultipleAnswer::remove_prefix(vec!['*'], &content), 55 | user_answer: UserAnswer::Undecided, 56 | }) 57 | } 58 | 59 | /// Remove prefix (* | -) from item 60 | fn remove_prefix(prefix: Vec, content: &String) -> Vec { 61 | content 62 | .lines() 63 | .filter(|item| prefix.contains(&item.chars().nth(1).unwrap())) 64 | .map(|item| item[3..].trim().to_string()) 65 | .collect() 66 | } 67 | } 68 | 69 | impl fmt::Display for MultipleAnswer { 70 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 71 | write!( 72 | f, 73 | "Question: {}\nChoices: {:?}\nAnswers: {:?}", 74 | self.question, self.choices.items, self.answers 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/models/card_types/multiple_choice.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use crate::{ 4 | models::{choice::Choice, errors::parsing_error::ParsingError, stateful_list::StatefulList}, 5 | Card, UserAnswer, 6 | }; 7 | 8 | pub struct MultipleChoice { 9 | pub question: String, 10 | pub choices: StatefulList, 11 | pub answers: Vec, 12 | 13 | pub user_answer: UserAnswer, 14 | } 15 | 16 | impl MultipleChoice { 17 | /// Validate the users current answer 18 | pub fn validate_answer(&mut self) -> UserAnswer { 19 | let choices = self 20 | .choices 21 | .items 22 | .iter() 23 | .filter(|item| item.selected) 24 | .map(|item| item.content.to_string()) 25 | .collect::>(); 26 | 27 | self.user_answer = if choices.is_empty() { 28 | UserAnswer::Undecided 29 | } else if choices == self.answers { 30 | UserAnswer::Correct 31 | } else { 32 | UserAnswer::Incorrect 33 | }; 34 | 35 | self.user_answer 36 | } 37 | 38 | pub fn instructions(&self) -> String { 39 | String::from(": Select/unselect choice") 40 | } 41 | 42 | pub fn parse_raw(content: String) -> Result { 43 | let (question, content) = Card::extract_card_title(&content)?; 44 | 45 | Ok(Self { 46 | question, 47 | choices: StatefulList::with_items( 48 | MultipleChoice::remove_prefix(vec!['-', '*'], &content) 49 | .iter() 50 | .map(|choice| Choice { 51 | // Todo: maybe don't clone? 52 | content: choice.clone(), 53 | selected: false, 54 | }) 55 | .collect(), 56 | ), 57 | answers: MultipleChoice::remove_prefix(vec!['*'], &content), 58 | user_answer: UserAnswer::Undecided, 59 | }) 60 | } 61 | 62 | /// Remove prefix (* | -) from item 63 | fn remove_prefix(prefix: Vec, content: &String) -> Vec { 64 | content 65 | .lines() 66 | // Todo: Don't unwrap 67 | .filter(|item| prefix.contains(&item.chars().nth(0).unwrap())) 68 | .map(|item| item[1..].trim().to_string()) 69 | .collect() 70 | } 71 | 72 | /// Unselect all items held within the internal vector 73 | pub fn unselect_all(&mut self) { 74 | for choice in self.choices.items.iter_mut() { 75 | choice.unselect(); 76 | } 77 | } 78 | } 79 | 80 | impl fmt::Display for MultipleChoice { 81 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 82 | write!( 83 | f, 84 | "Question: {}\nChoices: {:?}\nAnswers: {:?}", 85 | self.question, self.choices.items, self.answers 86 | ) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/models/card_types/order.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use rand::seq::SliceRandom; 4 | 5 | use crate::{ 6 | models::{choice::Choice, errors::parsing_error::ParsingError, stateful_list::StatefulList}, 7 | Card, UserAnswer, 8 | }; 9 | 10 | pub struct Order { 11 | pub question: String, 12 | pub shuffled: StatefulList, 13 | pub answer: Vec, 14 | 15 | pub user_answer: UserAnswer, 16 | } 17 | 18 | impl Order { 19 | pub fn instructions(&self) -> String { 20 | return String::from( 21 | ": Select item, use again on another item to swap them", 22 | ); 23 | } 24 | 25 | pub fn validate_answer(&mut self) -> UserAnswer { 26 | let choices = self 27 | .shuffled 28 | .items 29 | .iter() 30 | .map(|item| item.content.to_string()) 31 | .collect::>(); 32 | 33 | self.user_answer = if choices == self.answer { 34 | UserAnswer::Correct 35 | } else { 36 | UserAnswer::Incorrect 37 | }; 38 | 39 | self.user_answer 40 | } 41 | 42 | pub fn parse_raw(content: String) -> Result { 43 | let (question, content) = Card::extract_card_title(&content)?; 44 | let mut rng = rand::thread_rng(); 45 | 46 | let mut shuffled: Vec = content 47 | .lines() 48 | .map(|line| Choice { 49 | content: line[3..].to_string(), 50 | selected: false, 51 | }) 52 | .collect(); 53 | 54 | shuffled.shuffle(&mut rng); 55 | 56 | Ok(Self { 57 | question, 58 | shuffled: StatefulList::with_items(shuffled), 59 | answer: content.lines().map(|line| line[3..].to_string()).collect(), 60 | user_answer: UserAnswer::Undecided, 61 | }) 62 | } 63 | 64 | /// Check if there are multiple items currently selected 65 | pub fn multiple_selected(&self) -> Option<(usize, usize)> { 66 | let selected: Vec = self 67 | .shuffled 68 | .items 69 | .iter() 70 | .enumerate() 71 | .map(|(i, card)| if card.selected { i as i32 } else { -1 }) 72 | .filter(|item| *item >= 0) 73 | .collect(); 74 | 75 | if selected.len() != 2 { 76 | None 77 | } else { 78 | Some((selected[0] as usize, selected[1] as usize)) 79 | } 80 | } 81 | 82 | /// Unselect all items held within the internal vector 83 | pub fn unselect_all(&mut self) { 84 | for choice in self.shuffled.items.iter_mut() { 85 | choice.unselect(); 86 | } 87 | } 88 | } 89 | 90 | impl fmt::Display for Order { 91 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 92 | write!( 93 | f, 94 | "Question: {}\nShuffled: {:?}\nAnswer: {:?}", 95 | self.question, self.shuffled.items, self.answer 96 | ) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/models/choice.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub struct Choice { 3 | pub content: String, 4 | pub selected: bool, 5 | } 6 | 7 | impl Choice { 8 | /// Flip the current selected status 9 | pub fn select(&mut self) { 10 | self.selected = !self.selected; 11 | } 12 | 13 | /// Unselect the current card 14 | pub fn unselect(&mut self) { 15 | self.selected = false; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/models/errors/errors.rs: -------------------------------------------------------------------------------- 1 | use crossterm::style::Stylize; 2 | 3 | use super::{file_error::FileError, parsing_error::ParsingError}; 4 | use crate::reset_terminal; 5 | 6 | pub enum Errors { 7 | ParsingError(ParsingError), 8 | FileError(FileError), 9 | } 10 | 11 | impl Errors { 12 | fn throw_error(err_type: &str, err: String) -> ! { 13 | eprintln!("{}: {}", format!("{} Error", err_type).red().bold(), err); 14 | reset_terminal().unwrap(); 15 | std::process::exit(1); 16 | } 17 | 18 | pub fn throw_parsing_error(err: ParsingError) -> ! { 19 | Errors::throw_error("Parsing", err.to_string()) 20 | } 21 | 22 | pub fn throw_file_error(err: FileError) -> ! { 23 | Errors::throw_error("File", err.to_string()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/models/errors/file_error.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | pub enum FileError { 4 | InvalidFileType, 5 | } 6 | 7 | impl fmt::Display for FileError { 8 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 9 | match self { 10 | FileError::InvalidFileType => write!(f, "Invalid file type"), 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/models/errors/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod errors; 2 | pub mod file_error; 3 | pub mod parsing_error; 4 | -------------------------------------------------------------------------------- /src/models/errors/parsing_error.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | pub enum ParsingError { 4 | NoCardType, 5 | NoQuestion, 6 | NoContent, 7 | IncorrectDivider, 8 | } 9 | 10 | impl fmt::Display for ParsingError { 11 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 12 | match self { 13 | ParsingError::NoContent => { 14 | write!(f, "No available content to parse for one or more cards.") 15 | } 16 | ParsingError::NoQuestion => write!(f, "No question provided for one or more cards. The question must be prefixed with a hashtag (#)"), 17 | ParsingError::NoCardType => { 18 | write!(f, "One or more cards have not specified their card type.\nA list of all supported card types can be found here: https://brookjeynes.github.io/quiz-rs/cards") 19 | } 20 | ParsingError::IncorrectDivider => { 21 | write!(f, "One or more cards have an incorrect divider (---)") 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/models/file_type.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use std::ffi::OsStr; 3 | 4 | #[derive(PartialEq)] 5 | pub enum FileType { 6 | Markdown, 7 | } 8 | 9 | impl FileType { 10 | pub fn from_osstr(file_extension: &OsStr) -> Option { 11 | if file_extension == "md" { 12 | return Some(FileType::Markdown); 13 | } 14 | 15 | None 16 | } 17 | } 18 | 19 | impl fmt::Display for FileType { 20 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 21 | match self { 22 | FileType::Markdown => write!(f, "md"), 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod args; 2 | pub mod card; 3 | pub mod card_types; 4 | pub mod choice; 5 | pub mod file_type; 6 | pub mod stateful_list; 7 | pub mod user_answer; 8 | pub mod errors; 9 | -------------------------------------------------------------------------------- /src/models/stateful_list.rs: -------------------------------------------------------------------------------- 1 | use tui::widgets::ListState; 2 | 3 | pub struct StatefulList { 4 | pub state: ListState, 5 | pub items: Vec, 6 | } 7 | 8 | impl StatefulList { 9 | /// Create a StatefulList with the items passed in. 10 | pub fn with_items(items: Vec) -> Self { 11 | let mut stateful_list = Self { 12 | state: ListState::default(), 13 | items, 14 | }; 15 | 16 | // Auto select first item in decks list 17 | stateful_list.next(); 18 | 19 | stateful_list 20 | } 21 | 22 | /// Move the internally selected item forward. 23 | pub fn next(&mut self) { 24 | if !self.items.is_empty() { 25 | let i = match self.state.selected() { 26 | Some(i) => { 27 | if i >= self.items.len() - 1 { 28 | i 29 | } else { 30 | i + 1 31 | } 32 | } 33 | None => 0, 34 | }; 35 | 36 | self.state.select(Some(i)) 37 | } 38 | } 39 | 40 | /// Move the internally selected item backwards. 41 | pub fn previous(&mut self) { 42 | if !self.items.is_empty() { 43 | let i = match self.state.selected() { 44 | Some(i) => { 45 | if i == 0 { 46 | i 47 | } else { 48 | i - 1 49 | } 50 | } 51 | None => 0, 52 | }; 53 | 54 | self.state.select(Some(i)) 55 | } 56 | } 57 | 58 | /// Swap two items. 59 | pub fn swap(&mut self, a: usize, b: usize) { 60 | self.items.swap(a, b); 61 | } 62 | 63 | /// Return the selected items index. 64 | pub fn selected(&self) -> Option { 65 | if self.items.is_empty() { 66 | return None; 67 | } 68 | 69 | self.state.selected() 70 | } 71 | 72 | /// Return the selected items value. 73 | pub fn selected_value(&mut self) -> Option<&mut T> { 74 | match self.selected() { 75 | Some(index) => Some( 76 | self.items 77 | .get_mut(index) 78 | .expect("Will always return a valid card"), 79 | ), 80 | None => None, 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/models/user_answer.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq, Eq, Copy, Clone)] 2 | pub enum UserAnswer { 3 | Incorrect, 4 | Correct, 5 | Undecided, 6 | } 7 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | backend::Backend, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Style}, 5 | text::{Span, Spans}, 6 | widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, 7 | Frame, 8 | }; 9 | 10 | use crate::{ 11 | models::{ 12 | card::Card, card_types::fill_in_the_blanks::FillInTheBlanks, user_answer::UserAnswer, 13 | }, 14 | AppState, 15 | }; 16 | 17 | pub fn ui(f: &mut Frame, app_state: &mut AppState) { 18 | let mut card_question = String::new(); 19 | 20 | let default_instructions = 21 | "q: Quit application (unless specified otherwise), : Validate answer"; 22 | 23 | let size = f.size(); 24 | 25 | // A helper closure to create blocks 26 | let create_block = |title: &str| { 27 | Block::default() 28 | .borders(Borders::ALL) 29 | .title(title.to_string()) 30 | }; 31 | 32 | // A helper closure to create styled spans 33 | let create_styled_span = |content: &str, colour: Color| -> Span { 34 | Span::styled(content.to_string(), Style::default().fg(colour)) 35 | }; 36 | 37 | // The main canvas 38 | let chunks = Layout::default() 39 | .horizontal_margin(2) 40 | // Card (90%), spacer (5%), controls (5%) 41 | .constraints([ 42 | Constraint::Percentage(90), 43 | Constraint::Percentage(5), 44 | Constraint::Percentage(5), 45 | ]) 46 | .split(size); 47 | 48 | // The area held for the card 49 | let card_layout = Layout::default() 50 | .margin(size.area() / 800) 51 | // Title (30%) and content (70%) 52 | .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) 53 | .split(chunks[0]); 54 | 55 | // The area held within each card 56 | let inner_card_layout = Layout::default() 57 | .horizontal_margin(size.area() / 800) 58 | // Content (90%) and card footer (10%) 59 | .constraints([Constraint::Percentage(90), Constraint::Percentage(10)]) 60 | .split(card_layout[1]); 61 | 62 | // Create card footer content 63 | let incorrect = Paragraph::new(create_styled_span( 64 | app_state.score.incorrect.to_string().as_ref(), 65 | Color::Red, 66 | )) 67 | .alignment(Alignment::Left); 68 | 69 | let cards = Paragraph::new(format!( 70 | "{}/{}", 71 | app_state 72 | .cards 73 | .selected() 74 | .expect("This should never be None when this is called.") 75 | + 1, 76 | app_state.cards.items.len() 77 | )) 78 | .alignment(Alignment::Center); 79 | 80 | let correct = Paragraph::new(Span::styled( 81 | app_state.score.correct.to_string(), 82 | Style::default().fg(Color::Green), 83 | )) 84 | .alignment(Alignment::Right); 85 | 86 | if let Some(val) = app_state.cards.selected_value() { 87 | match val { 88 | Card::FlashCard(card) => { 89 | card_question = card.question.clone(); 90 | 91 | let answer = Paragraph::new(if card.flipped { 92 | card.answer.to_string() 93 | } else { 94 | String::new() 95 | }) 96 | .block(create_block("Answer")) 97 | .wrap(Wrap { trim: false }) 98 | .alignment(Alignment::Center); 99 | 100 | if card.show_validation_popup { 101 | let area = centered_rect(60, 20, size); 102 | let paragraph = Paragraph::new("Did you get this card correct? y/n") 103 | .block(create_block("Validate")) 104 | .alignment(Alignment::Center); 105 | 106 | f.render_widget(Clear, area); //this clears out the background 107 | f.render_widget(paragraph, area); 108 | } 109 | 110 | f.render_widget(answer, card_layout[1]); 111 | } 112 | Card::MultipleChoice(card) => { 113 | card_question = card.question.clone(); 114 | let choices: Vec = card 115 | .choices 116 | .items 117 | .iter() 118 | .map(|choice| { 119 | ListItem::new(create_styled_span( 120 | choice.content.as_ref(), 121 | match choice.selected { 122 | true => match card.user_answer { 123 | UserAnswer::Correct => Color::Green, 124 | UserAnswer::Incorrect => Color::Red, 125 | UserAnswer::Undecided => Color::Blue, 126 | }, 127 | false => match card.user_answer { 128 | UserAnswer::Incorrect 129 | if card.answers.contains(&choice.content) => 130 | { 131 | Color::Green 132 | } 133 | _ => Color::White, 134 | }, 135 | }, 136 | )) 137 | }) 138 | .collect(); 139 | 140 | let choices_list = List::new(choices) 141 | .block(create_block("Choices")) 142 | .highlight_symbol("> "); 143 | 144 | f.render_stateful_widget(choices_list, card_layout[1], &mut card.choices.state); 145 | } 146 | Card::MultipleAnswer(card) => { 147 | card_question = card.question.clone(); 148 | 149 | let choices: Vec = card 150 | .choices 151 | .items 152 | .iter() 153 | .map(|choice| match choice.selected { 154 | true => ListItem::new(create_styled_span( 155 | format!("[x] {}", choice.content).as_str(), 156 | match card.user_answer { 157 | UserAnswer::Correct => Color::Green, 158 | UserAnswer::Incorrect => Color::Red, 159 | UserAnswer::Undecided => Color::White, 160 | }, 161 | )), 162 | false => ListItem::new(create_styled_span( 163 | format!("[ ] {}", choice.content).as_str(), 164 | match card.user_answer { 165 | UserAnswer::Correct if card.answers.contains(&choice.content) => { 166 | Color::Green 167 | } 168 | _ => Color::White, 169 | }, 170 | )), 171 | }) 172 | .collect(); 173 | 174 | let choices_list = List::new(choices) 175 | .block(create_block("Choices")) 176 | .highlight_symbol("> "); 177 | 178 | f.render_stateful_widget(choices_list, card_layout[1], &mut card.choices.state); 179 | } 180 | Card::FillInTheBlanks(card) => { 181 | card_question = card.question.clone(); 182 | 183 | let content = Paragraph::new(match card.user_answer { 184 | UserAnswer::Undecided => vec![Spans::from(card.output.to_string())], 185 | _ => card.validated_output(), 186 | }) 187 | .block(create_block("Content")) 188 | .wrap(Wrap { trim: false }) 189 | .alignment(Alignment::Center); 190 | 191 | f.render_widget(content, card_layout[1]); 192 | } 193 | Card::Order(card) => { 194 | card_question = card.question.clone(); 195 | 196 | let choices: Vec = card 197 | .shuffled 198 | .items 199 | .iter() 200 | .enumerate() 201 | .map(|(i, choice)| match choice.selected { 202 | true => ListItem::new(Spans::from(vec![ 203 | Span::raw(format!("{}. ", i + 1)), 204 | create_styled_span(format!("{}", choice.content).as_ref(), Color::Blue), 205 | ])), 206 | false => ListItem::new(Spans::from(vec![create_styled_span( 207 | format!("{}. {}", i + 1, choice.content).as_ref(), 208 | match card.user_answer { 209 | UserAnswer::Correct => Color::Green, 210 | UserAnswer::Incorrect => Color::Red, 211 | UserAnswer::Undecided => Color::White, 212 | }, 213 | )])), 214 | }) 215 | .collect(); 216 | 217 | let choices_list = List::new(choices) 218 | .block(create_block("Choices")) 219 | .highlight_symbol("> "); 220 | 221 | f.render_stateful_widget(choices_list, card_layout[1], &mut card.shuffled.state); 222 | } 223 | } 224 | }; 225 | 226 | // Render card title 227 | f.render_widget( 228 | Paragraph::new(card_question) 229 | .block(create_block("Question")) 230 | .wrap(Wrap { trim: false }) 231 | .alignment(Alignment::Center), 232 | card_layout[0], 233 | ); 234 | 235 | let instructions = match app_state.cards.selected_value() { 236 | Some(card) => card.instructions(), 237 | None => String::new(), 238 | }; 239 | 240 | // Render instructions from our card instance 241 | f.render_widget( 242 | Paragraph::new(format!("{}\n{}", instructions, default_instructions)) 243 | .alignment(Alignment::Left), 244 | chunks[2], 245 | ); 246 | 247 | // Render card footer 248 | f.render_widget(incorrect, inner_card_layout[1]); 249 | f.render_widget(cards, inner_card_layout[1]); 250 | f.render_widget(correct, inner_card_layout[1]); 251 | } 252 | 253 | /// helper function to create a centered rect using up certain percentage of the available rect `r` 254 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 255 | let popup_layout = Layout::default() 256 | .direction(Direction::Vertical) 257 | .constraints( 258 | [ 259 | Constraint::Percentage((100 - percent_y) / 2), 260 | Constraint::Percentage(percent_y), 261 | Constraint::Percentage((100 - percent_y) / 2), 262 | ] 263 | .as_ref(), 264 | ) 265 | .split(r); 266 | 267 | Layout::default() 268 | .direction(Direction::Horizontal) 269 | .constraints( 270 | [ 271 | Constraint::Percentage((100 - percent_x) / 2), 272 | Constraint::Percentage(percent_x), 273 | Constraint::Percentage((100 - percent_x) / 2), 274 | ] 275 | .as_ref(), 276 | ) 277 | .split(popup_layout[1])[1] 278 | } 279 | 280 | impl FillInTheBlanks { 281 | pub fn validated_output(&mut self) -> Vec { 282 | let new_content = self 283 | .content 284 | .split("__") 285 | .take(self.answers.len()) 286 | .enumerate() 287 | .map(|(index, item)| { 288 | let user_content = match self.user_input.get(index) { 289 | Some(content) => content, 290 | None => "", 291 | }; 292 | 293 | vec![ 294 | Span::from(item), 295 | Span::styled( 296 | user_content, 297 | Style::default().fg( 298 | if self 299 | .answers 300 | .get(&index) 301 | .unwrap_or(&vec![]) 302 | .contains(&user_content.to_string()) 303 | { 304 | Color::Green 305 | } else { 306 | Color::Red 307 | }, 308 | ), 309 | ), 310 | ] 311 | }) 312 | .flatten() 313 | .collect::>(); 314 | 315 | vec![Spans::from(new_content)] 316 | } 317 | } 318 | --------------------------------------------------------------------------------