diff options
-rw-r--r-- | Cargo.lock | 729 | ||||
-rw-r--r-- | Cargo.toml | 12 | ||||
-rw-r--r-- | README.md | 4 | ||||
-rwxr-xr-x | bin/dev-server (renamed from bin/watch.sh) | 0 | ||||
-rw-r--r-- | flake.lock | 36 | ||||
-rw-r--r-- | flake.nix | 2 | ||||
-rw-r--r-- | src/deck.rs | 145 | ||||
-rw-r--r-- | src/gui/message.rs | 16 | ||||
-rw-r--r-- | src/gui/mod.rs | 70 | ||||
-rw-r--r-- | src/gui/question.rs | 188 | ||||
-rw-r--r-- | src/main.rs | 38 | ||||
-rw-r--r-- | src/model/difficulty.rs | 2 | ||||
-rw-r--r-- | src/model/mod.rs | 2 | ||||
-rw-r--r-- | src/sync.rs | 206 | ||||
-rw-r--r-- | src/util/event.rs | 73 | ||||
-rw-r--r-- | src/util/mod.rs | 1 | ||||
-rw-r--r-- | src/util/serialization.rs | 18 |
17 files changed, 1003 insertions, 539 deletions
@@ -4,46 +4,96 @@ version = 3 [[package]] name = "ahash" -version = "0.7.6" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" dependencies = [ - "getrandom", + "cfg-if", "once_cell", "version_check", + "zerocopy", ] [[package]] -name = "ansi_term" -version = "0.12.1" +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "android-tzdata" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "winapi", + "libc", ] [[package]] -name = "anyhow" -version = "1.0.45" +name = "anstream" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee10e43ae4a853c0a3591d4e2ada1719e553be18199d9da9d4a83f5927c2f5c7" +checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] [[package]] -name = "atty" -version = "0.2.14" +name = "anstyle" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anstyle-parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" dependencies = [ - "hermit-abi", - "libc", - "winapi", + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys", ] [[package]] +name = "anstyle-wincon" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + +[[package]] name = "autocfg" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "bitflags" @@ -52,12 +102,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] +name = "bitflags" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] name = "cassowary" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -65,33 +136,102 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ - "libc", - "num-integer", + "android-tzdata", + "iana-time-zone", + "js-sys", "num-traits", - "time", - "winapi", + "wasm-bindgen", + "windows-targets", ] [[package]] name = "clap" -version = "2.34.0" +version = "4.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +checksum = "41fffed7514f420abec6d183b1d3acfd9099c79c3a10a06ade4f8203f1411272" dependencies = [ - "ansi_term", - "atty", - "bitflags", + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63361bae7eef3771745f02d8d892bec2fee5f6e34af316ba556e7f97a7069ff1" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", "strsim", - "textwrap", - "unicode-width", - "vec_map", ] [[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.1", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + +[[package]] name = "fallible-iterator" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -109,248 +249,298 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "clap", + "crossterm", + "ratatui", "rusqlite", "rusqlite_migration", "serde", "serde_json", - "structopt", - "termion", - "tui", -] - -[[package]] -name = "getrandom" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" -dependencies = [ - "cfg-if", - "libc", - "wasi", ] [[package]] name = "hashbrown" -version = "0.11.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" dependencies = [ "ahash", + "allocator-api2", ] [[package]] name = "hashlink" -version = "0.7.0" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" dependencies = [ "hashbrown", ] [[package]] name = "heck" -version = "0.3.3" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" dependencies = [ - "unicode-segmentation", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "hermit-abi" -version = "0.1.19" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "libc", + "cc", +] + +[[package]] +name = "indoc" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", ] [[package]] name = "itoa" -version = "0.4.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] -name = "lazy_static" -version = "1.4.0" +name = "js-sys" +version = "0.3.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "cee9c64da59eae3b50095c18d3e74f8b73c0b86d2792824ff01bbce68ba229ca" +dependencies = [ + "wasm-bindgen", +] [[package]] name = "libc" -version = "0.2.105" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869d572136620d55835903746bcb5cdc54cb2851fd0aeec53220b4bb65ef3013" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" [[package]] name = "libsqlite3-sys" -version = "0.23.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd5850c449b40bacb498b2bbdfaff648b1b055630073ba8db499caf2d0ea9f2" +checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" dependencies = [ "pkg-config", "vcpkg", ] [[package]] -name = "log" -version = "0.4.14" +name = "lock_api" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" dependencies = [ - "cfg-if", + "autocfg", + "scopeguard", ] [[package]] -name = "memchr" -version = "2.4.1" +name = "log" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] -name = "num-integer" -version = "0.1.44" +name = "lru" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +checksum = "2994eeba8ed550fd9b47a0b38f0242bc3344e496483c6180b69139cc2fa5d1d7" dependencies = [ - "autocfg", - "num-traits", + "hashbrown", ] [[package]] -name = "num-traits" -version = "0.2.14" +name = "mio" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" dependencies = [ - "autocfg", + "libc", + "log", + "wasi", + "windows-sys", ] [[package]] -name = "numtoa" -version = "0.1.0" +name = "num-traits" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", +] [[package]] name = "once_cell" -version = "1.8.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] -name = "pkg-config" -version = "0.3.22" +name = "parking_lot" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "parking_lot_core" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn", - "version_check", + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "paste" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" [[package]] name = "proc-macro2" -version = "1.0.32" +version = "1.0.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] name = "quote" -version = "1.0.10" +version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ "proc-macro2", ] [[package]] -name = "redox_syscall" -version = "0.2.10" +name = "ratatui" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" dependencies = [ - "bitflags", + "bitflags 2.4.1", + "cassowary", + "crossterm", + "indoc", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-width", ] [[package]] -name = "redox_termios" -version = "0.1.2" +name = "redox_syscall" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "redox_syscall", + "bitflags 1.3.2", ] [[package]] name = "rusqlite" -version = "0.26.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a82b0b91fad72160c56bf8da7a549b25d7c31109f52cc1437eac4c0ad2550a7" +checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2" dependencies = [ - "bitflags", + "bitflags 2.4.1", "chrono", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", - "memchr", "smallvec", ] [[package]] name = "rusqlite_migration" -version = "0.5.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2abaa6d2e015b59342d088590b0c0051ba1ed5508780f8c31315b2986ebbf5b6" +checksum = "ef7dd29a4426624704d5966416682fb7ab3682f724986e9e3893eaca44accabc" dependencies = [ "log", "rusqlite", ] [[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] name = "ryu" -version = "1.0.5" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.130" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.130" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", @@ -359,9 +549,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.70" +version = "1.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e277c495ac6cd1a01a58d0a0c574568b4d1ddf14f59965c6a58b8d96400b54f3" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" dependencies = [ "itoa", "ryu", @@ -369,137 +559,175 @@ dependencies = [ ] [[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] name = "smallvec" -version = "1.7.0" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" [[package]] name = "strsim" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] -name = "structopt" -version = "0.3.26" +name = "strum" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" dependencies = [ - "clap", - "lazy_static", - "structopt-derive", + "strum_macros", ] [[package]] -name = "structopt-derive" -version = "0.4.18" +name = "strum_macros" +version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" dependencies = [ "heck", - "proc-macro-error", "proc-macro2", "quote", + "rustversion", "syn", ] [[package]] name = "syn" -version = "1.0.81" +version = "2.0.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] [[package]] -name = "termion" -version = "1.5.6" +name = "unicode-ident" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" -dependencies = [ - "libc", - "numtoa", - "redox_syscall", - "redox_termios", -] +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] -name = "textwrap" -version = "0.11.0" +name = "unicode-segmentation" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" -dependencies = [ - "unicode-width", -] +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" [[package]] -name = "time" -version = "0.1.43" +name = "unicode-width" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" -dependencies = [ - "libc", - "winapi", -] +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] -name = "tui" -version = "0.16.0" +name = "utf8parse" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39c8ce4e27049eed97cfa363a5048b09d995e209994634a0efc26a14ab6c0c23" -dependencies = [ - "bitflags", - "cassowary", - "termion", - "unicode-segmentation", - "unicode-width", -] +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] -name = "unicode-segmentation" -version = "1.8.0" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "unicode-width" -version = "0.1.9" +name = "version_check" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] -name = "unicode-xid" -version = "0.2.2" +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "vcpkg" -version = "0.2.15" +name = "wasm-bindgen" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] [[package]] -name = "vec_map" -version = "0.8.2" +name = "wasm-bindgen-backend" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" +checksum = "1b56f625e64f3a1084ded111c4d5f477df9f8c92df113852fa5a374dbda78826" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] [[package]] -name = "version_check" -version = "0.9.3" +name = "wasm-bindgen-macro" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +checksum = "0162dbf37223cd2afce98f3d0785506dcb8d266223983e4b5b525859e6e182b2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" +name = "wasm-bindgen-macro-support" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f" [[package]] name = "winapi" @@ -522,3 +750,98 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "zerocopy" +version = "0.7.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f15f7ade05d2a4935e34a457b936c23dc70a05cc1d97133dc99e7a3fe0f0e" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbbad221e3f78500350ecbd7dfa4e63ef945c05f4c61cb7f4d3f84cd0bba649b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] @@ -2,15 +2,15 @@ name = "flashcards" version = "0.1.0" authors = ["Joris GUYONVARCH"] -edition = "2018" +edition = "2021" [dependencies] anyhow = "1.0" chrono = "0.4" -rusqlite = { version = "0.26", features = [ "chrono" ] } -rusqlite_migration = "0.5" +clap = { version = "4.4", features = ["derive"] } +crossterm = { version = "0.27" } +rusqlite = { version = "0.29", features = [ "chrono" ] } +rusqlite_migration = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -structopt = "0.3" -termion = "1.5" -tui = "0.16" +tui = { package = "ratatui", version = "0.24", features = ["crossterm"] } @@ -1,7 +1,7 @@ # Getting started ```bash -nix develop --command bin/watch.sh +nix develop --command bin/dev-server.sh ``` # Screenshot @@ -22,7 +22,7 @@ Cards are created from a plain text `./deck` file: - good moorning : bonjour - alternative 1 | alternative 2 : choix 1 | choix 2 -- cat (indication) : chat +- cat (indication) : chat [ʃa] ``` # Backlog diff --git a/bin/watch.sh b/bin/dev-server index e3a6c28..e3a6c28 100755 --- a/bin/watch.sh +++ b/bin/dev-server @@ -1,12 +1,15 @@ { "nodes": { "flake-utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1667395993, - "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", "owner": "numtide", "repo": "flake-utils", - "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", "type": "github" }, "original": { @@ -17,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1669040464, - "narHash": "sha256-A+9mPkCdd6ei8EfbRX8butcneRuXbwiGpoyeh9TbAwg=", + "lastModified": 1701550882, + "narHash": "sha256-nt/o7lLaIkBpPkVviBqxp/HMEtEfVJzLl2eHzuJF9/I=", "owner": "nixos", "repo": "nixpkgs", - "rev": "328d723f89af95a280d3046e6124786b03b0e2bf", + "rev": "c3857011d76ef8d137ac9abb5b0a8c957961a471", "type": "github" }, "original": { @@ -47,11 +50,11 @@ ] }, "locked": { - "lastModified": 1668998422, - "narHash": "sha256-G/BklIplCHZEeDIabaaxqgITdIXtMolRGlwxn9jG2/Q=", + "lastModified": 1701483183, + "narHash": "sha256-MDH3oUajqTaYClCiq1QK7jWVMtMFDJWxVBCFAnkt6J4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "68ab029c93f8f8eed4cf3ce9a89a9fd4504b2d6e", + "rev": "47fe4578cb64a365f400e682a70e054657c42fa5", "type": "github" }, "original": { @@ -59,6 +62,21 @@ "repo": "rust-overlay", "type": "github" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", @@ -19,7 +19,7 @@ { devShell = mkShell { buildInputs = [ - rust-bin.stable."1.60.0".default + rust-bin.stable."1.74.0".default watchexec cargo-watch sqlite diff --git a/src/deck.rs b/src/deck.rs index 0c302e1..4491d9b 100644 --- a/src/deck.rs +++ b/src/deck.rs @@ -23,51 +23,57 @@ impl std::error::Error for ParseError { } } -pub fn read(deck: &str) -> Result<Vec<Line>> { - let file = File::open(deck)?; +pub fn read_file(path: &str) -> Result<Vec<Line>> { + let file = File::open(path)?; let reader = BufReader::new(file); let mut entries: Vec<Line> = Vec::new(); for (index, line) in reader.lines().enumerate() { let line = line?; - let line = line.trim(); - if !line.starts_with('#') && !line.is_empty() { - if !line.starts_with('-') { - return Err(Error::from(ParseError { + if let Some(line) = read_line(index, &line)? { + entries.push(line) + } + } + + Ok(entries) +} + +fn read_line(index: usize, line: &str) -> Result<Option<Line>> { + let line = line.trim(); + + if line.starts_with('#') || line.is_empty() { + Ok(None) + } else if !line.starts_with('-') { + Err(Error::from(ParseError { + line: index + 1, + message: "an entry should starts with “-”.".to_string(), + })) + } else { + let without_minus = line.split('-').skip(1).collect::<Vec<&str>>().join("-"); + let without_comment = without_minus.split('#').collect::<Vec<&str>>()[0].trim(); + let translation = without_comment.split(':').collect::<Vec<&str>>(); + if translation.len() != 2 { + Err(Error::from(ParseError { + line: index + 1, + message: "an entry should contain two parts separated by “:”.".to_string(), + })) + } else { + let t1 = translation[0].trim(); + let t2 = translation[1].trim(); + if t1.is_empty() || t2.is_empty() { + Err(Error::from(ParseError { line: index + 1, - message: "an entry should starts with “-”.".to_string(), - })); + message: "an entry should contain two parts separated by “:”.".to_string(), + })) } else { - let without_minus = line.split('-').skip(1).collect::<Vec<&str>>().join("-"); - let without_comment = without_minus.split('#').collect::<Vec<&str>>()[0].trim(); - let translation = without_comment.split(':').collect::<Vec<&str>>(); - if translation.len() != 2 { - return Err(Error::from(ParseError { - line: index + 1, - message: "an entry should contain two parts separated by “:”.".to_string(), - })); - } else { - let t1 = translation[0].trim(); - let t2 = translation[1].trim(); - if t1.is_empty() || t2.is_empty() { - return Err(Error::from(ParseError { - line: index + 1, - message: "an entry should contain two parts separated by “:”." - .to_string(), - })); - } else { - entries.push(Line { - part_1: serialization::line_to_words(t1), - part_2: serialization::line_to_words(t2), - }) - } - } + Ok(Some(Line { + part_1: serialization::line_to_words(t1), + part_2: serialization::line_to_words(t2), + })) } } } - - Ok(entries) } pub fn pp_from_path(path: &str) -> Option<String> { @@ -83,3 +89,74 @@ fn capitalize(s: &str) -> String { Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), } } + +#[cfg(test)] +pub mod tests { + + use crate::model::Line; + use anyhow::Result; + + #[test] + fn errors() { + is_error("A : a"); + is_error("- A"); + is_error("- A -> a"); + is_error("- A : B : C"); + is_error("- : "); + is_error("- A : a\n-") + } + + #[test] + fn ignored() { + check("", &[]); + check(" ", &[]); + check(" \n \n ", &[]); + check("# 1", &[]); + check("# 1\n\n # 2", &[]); + } + + #[test] + fn card() { + check("- A : a", &[(&["A"], &["a"])]); + } + + #[test] + fn cards() { + check("- A : a\n- B : b", &[(&["A"], &["a"]), (&["B"], &["b"])]); + } + + #[test] + fn alternatives() { + check("- A : a1 | a2", &[(&["A"], &["a1", "a2"])]); + check("- A1 | A2 : a", &[(&["A1", "A2"], &["a"])]); + check("- A1 | A2 : a1 | a2", &[(&["A1", "A2"], &["a1", "a2"])]); + } + + fn is_error(content: &str) { + assert!(read_string(content).is_err()) + } + + fn check(content: &str, res: &[(&[&str], &[&str])]) { + assert_eq!( + read_string(content).unwrap(), + res.iter() + .map(|(part_1, part_2)| Line { + part_1: part_1.iter().map(|x| x.to_string()).collect::<Vec<_>>(), + part_2: part_2.iter().map(|x| x.to_string()).collect::<Vec<_>>() + }) + .collect::<Vec<_>>() + ) + } + + pub fn read_string(content: &str) -> Result<Vec<Line>> { + let mut entries: Vec<Line> = Vec::new(); + + for (index, line) in content.lines().enumerate() { + if let Some(line) = super::read_line(index, line)? { + entries.push(line) + } + } + + Ok(entries) + } +} diff --git a/src/gui/message.rs b/src/gui/message.rs index 29b5d8a..61f57ba 100644 --- a/src/gui/message.rs +++ b/src/gui/message.rs @@ -1,7 +1,6 @@ use crate::gui::util; -use crate::util::event::{Event, Events}; use anyhow::Result; -use termion::event::Key; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use tui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout}, @@ -11,7 +10,6 @@ use tui::{ pub fn show<B: Backend>( terminal: &mut Terminal<B>, - events: &Events, title: &str, message: &str, wait: bool, @@ -33,12 +31,12 @@ pub fn show<B: Backend>( })?; if wait { - if let Event::Input(key) = events.next()? { - match key { - Key::Char('q') | Key::Ctrl('c') => { - break; - } - _ => {} + if let Event::Key(key) = event::read()? { + if key.code == KeyCode::Char('q') + || key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + break; } } } else { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 358e4b5..3abe238 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2,34 +2,59 @@ pub mod message; pub mod question; pub mod util; -use crate::{db, space_repetition, util::event::Events, util::time}; +use crate::sync; +use crate::{db, space_repetition, util::time}; use anyhow::Result; +use crossterm::terminal; use rusqlite::Connection; -use std::io; -use termion::{raw::IntoRawMode, raw::RawTerminal, screen::AlternateScreen}; -use tui::{backend::TermionBackend, Terminal}; +use std::fs; +use std::io::Stdout; +use tui::{backend::CrosstermBackend, Terminal}; -pub type Term = Terminal<TermionBackend<AlternateScreen<RawTerminal<io::Stdout>>>>; +pub type Term = Terminal<CrosstermBackend<Stdout>>; -pub fn terminal() -> Result<Term> { - let stdout = io::stdout().into_raw_mode()?; - let stdout = AlternateScreen::from(stdout); - let backend = TermionBackend::new(stdout); +pub fn setup_terminal() -> Result<Term> { + terminal::enable_raw_mode()?; + let mut stdout = std::io::stdout(); + crossterm::execute!(stdout, terminal::EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); Ok(Terminal::new(backend)?) } -pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &str) -> Result<()> { - let mut answers = 0; +pub fn restore_terminal(term: &mut Term) -> Result<()> { + terminal::disable_raw_mode()?; + crossterm::execute!(term.backend_mut(), terminal::LeaveAlternateScreen)?; + term.show_cursor()?; + Ok(()) +} +pub fn start( + conn: &mut Connection, + term: &mut Term, + deck_path: &str, + deck_name: &str, + mut deck_last_sync: u64, + hide_remaining: bool, +) -> Result<()> { loop { - let now = time::seconds_since_unix_epoch()?; - let title = title(deck_name, answers, db::count_available(conn).unwrap_or(0)); + // Synchronize deck if necessary + let deck_last_update = + time::seconds_since_unix_epoch_of(fs::metadata(deck_path)?.modified()?)?; + if deck_last_update > deck_last_sync { + sync::run(conn, deck_path)?; + deck_last_sync = time::seconds_since_unix_epoch()?; + } + + let title = title( + deck_name, + db::count_available(conn).unwrap_or(0), + hide_remaining, + ); match db::pick_random_ready(conn) { - Some(card) => match question::ask(term, events, &title, &card)? { + Some(card) => match question::ask(term, &title, &card)? { question::Response::Aborted => break, question::Response::Answered { difficulty } => { - answers += 1; db::update( conn, &card.question, @@ -40,12 +65,13 @@ pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &st None => { let message = match db::next_ready(conn) { Some(ready) => { + let now = time::seconds_since_unix_epoch()?; let duration = time::pp_duration(ready - now); format!("Prochaine carte disponible dans {duration}.") } None => "Aucune carte n’est disponible. Votre deck est-il vide ?".to_string(), }; - let _ = message::show(term, events, &title, &message, true); + let _ = message::show(term, &title, &message, true); break; } } @@ -54,16 +80,10 @@ pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &st Ok(()) } -fn title(deck_name: &str, answers: i32, available_cards: i32) -> String { - if answers == 0 && available_cards == 0 { +fn title(deck_name: &str, available_cards: i32, hide_remaining: bool) -> String { + if available_cards == 0 || hide_remaining { deck_name.to_string() - } else if available_cards == 0 { - let from = answers; - let to = answers + available_cards; - format!("{deck_name} ({from} / {to})") } else { - let from = answers + 1; - let to = answers + available_cards; - format!("{deck_name} ({from} / {to})") + format!("{deck_name} ({available_cards})") } } diff --git a/src/gui/question.rs b/src/gui/question.rs index 2aa6e65..512ca49 100644 --- a/src/gui/question.rs +++ b/src/gui/question.rs @@ -1,17 +1,16 @@ use crate::{ gui::util, model::{difficulty, difficulty::Difficulty, Card}, - util::event::{Event, Events}, util::serialization, }; use anyhow::Result; -use termion::event::Key; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use tui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - text::{Span, Spans, Text}, - widgets::{Block, Borders, Paragraph, Wrap}, + text::{Line, Span, Text}, + widgets::{Paragraph, Wrap}, Terminal, }; @@ -30,12 +29,7 @@ pub enum Response { Answered { difficulty: Difficulty }, } -pub fn ask<B: Backend>( - terminal: &mut Terminal<B>, - events: &Events, - title: &str, - card: &Card, -) -> Result<Response> { +pub fn ask<B: Backend>(terminal: &mut Terminal<B>, title: &str, card: &Card) -> Result<Response> { let mut state = State { input: String::new(), answer: Answer::Write, @@ -62,16 +56,6 @@ pub fn ask<B: Backend>( f.render_widget(d1, chunks[0]); let question = Paragraph::new(util::center_vertically(chunks[1], &card.question)) - .style(match state.answer { - Answer::Write => { - if state.input.trim().is_empty() { - Style::default().fg(Color::Yellow) - } else { - Style::default() - } - } - _ => Style::default(), - }) .alignment(Alignment::Center); f.render_widget(question, chunks[1]); @@ -83,15 +67,15 @@ pub fn ask<B: Backend>( .style(match state.answer { Answer::Write => Style::default(), Answer::Difficulty { difficulty: _ } => { - if is_correct(&state.input, &card.responses) { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::Red) + match check_response(&state.input, &card.responses) { + CheckResponse::Correct { phonetics: _ } => { + Style::default().fg(Color::Green) + } + CheckResponse::Incorrect => Style::default().fg(Color::Red), } } }) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).title("Réponse")) .wrap(Wrap { trim: true }); f.render_widget(answer, chunks[2]); @@ -99,12 +83,17 @@ pub fn ask<B: Backend>( difficulty: selected, } = state.answer { - if !is_correct(&state.input, &card.responses) { - let paragraph = Paragraph::new(util::center_vertically( - chunks[3], - &serialization::words_to_line(&card.responses), - )) - .alignment(Alignment::Center); + let maybe_indication: Option<String> = + match check_response(&state.input, &card.responses) { + CheckResponse::Correct { phonetics } => phonetics, + CheckResponse::Incorrect => { + Some(serialization::words_to_line(&card.responses)) + } + }; + + if let Some(indication) = maybe_indication { + let paragraph = Paragraph::new(util::center_vertically(chunks[3], &indication)) + .alignment(Alignment::Center); f.render_widget(paragraph, chunks[3]); }; @@ -131,73 +120,84 @@ pub fn ask<B: Backend>( }) .collect::<Vec<Vec<Span>>>() .concat(); - let p = Paragraph::new(Text::from(Spans::from(tabs))).alignment(Alignment::Center); + let p = Paragraph::new(Text::from(Line::from(tabs))).alignment(Alignment::Center); f.render_widget(p, chunks[4]); } })?; - if let Event::Input(key) = events.next()? { + if let Event::Key(key) = event::read()? { match state.answer { - Answer::Write => match key { - Key::Char('\n') => { - let difficulty = if is_correct(&state.input, &card.responses) { + Answer::Write => match key.code { + KeyCode::Enter => { + let difficulty = if state.input.is_empty() { + // Encourage solving without typing by defaulting to good answer Difficulty::Good } else { - Difficulty::Again + match check_response(&state.input, &card.responses) { + CheckResponse::Correct { phonetics: _ } => Difficulty::Good, + CheckResponse::Incorrect => Difficulty::Again, + } }; state.answer = Answer::Difficulty { difficulty } } - Key::Char(c) => { - state.input.push(c); - if is_correct(&state.input, &card.responses) { - state.answer = Answer::Difficulty { - difficulty: Difficulty::Good, + KeyCode::Char(c) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + if c == 'u' { + state.input.clear(); + } else if c == 'w' { + let mut words = + state.input.split_whitespace().collect::<Vec<&str>>(); + if !words.is_empty() { + words.truncate(words.len() - 1); + let joined_words = words.join(" "); + let space = if !words.is_empty() { " " } else { "" }; + state.input = format!("{joined_words}{space}"); + } + } else if c == 'c' { + return Ok(Response::Aborted); + } + } else { + state.input.push(c); + if let CheckResponse::Correct { phonetics: _ } = + check_response(&state.input, &card.responses) + { + state.answer = Answer::Difficulty { + difficulty: Difficulty::Good, + } } } } - Key::Backspace => { + KeyCode::Backspace => { state.input.pop(); } - Key::Ctrl('u') => { - state.input.clear(); - } - Key::Ctrl('w') => { - let mut words = state.input.split_whitespace().collect::<Vec<&str>>(); - if !words.is_empty() { - words.truncate(words.len() - 1); - state.input = format!( - "{}{}", - words.join(" "), - if !words.is_empty() { " " } else { "" } - ); - } - } - Key::Ctrl('c') => { - return Ok(Response::Aborted); - } _ => {} }, Answer::Difficulty { difficulty: selected, - } => match key { - Key::Left => { - for d in relative_element(&card.state.difficulties(), &selected, -1).iter() + } => match key.code { + KeyCode::Left => { + if let Some(difficulty) = + relative_element(&card.state.difficulties(), &selected, -1) { - state.answer = Answer::Difficulty { difficulty: *d } + state.answer = Answer::Difficulty { difficulty } } } - Key::Right => { - for d in relative_element(&card.state.difficulties(), &selected, 1).iter() { - state.answer = Answer::Difficulty { difficulty: *d } + KeyCode::Right => { + if let Some(difficulty) = + relative_element(&card.state.difficulties(), &selected, 1) + { + state.answer = Answer::Difficulty { difficulty } } } - Key::Char('\n') => { + KeyCode::Enter => { return Ok(Response::Answered { difficulty: selected, }) } - Key::Ctrl('c') => { - return Ok(Response::Aborted); + KeyCode::Char('c') => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(Response::Aborted); + } } _ => {} }, @@ -206,18 +206,50 @@ pub fn ask<B: Backend>( } } -fn is_correct(input: &str, responses: &[String]) -> bool { - // Remove whitespaces - let input = input +enum CheckResponse { + Incorrect, + Correct { phonetics: Option<String> }, +} + +fn check_response(input: &str, responses: &[String]) -> CheckResponse { + let input = remove_whitespaces(input); + + responses + .iter() + .find(|r| remove_indications_and_phonetics(r) == input) + .map(|r| CheckResponse::Correct { + phonetics: extract_phonetics(r), + }) + .unwrap_or(CheckResponse::Incorrect) +} + +fn remove_whitespaces(input: &str) -> String { + input .split_whitespace() .map(|word| word.trim()) .collect::<Vec<&str>>() - .join(" "); + .join(" ") +} - responses - .iter() - .map(|r| r.split('(').collect::<Vec<&str>>()[0].trim()) - .any(|x| x == input) +fn remove_indications_and_phonetics(response: &str) -> &str { + response + .split(|c| c == '(' || c == '[') + .collect::<Vec<&str>>()[0] + .trim() +} + +fn extract_phonetics(response: &str) -> Option<String> { + let s1 = response.split('[').collect::<Vec<&str>>(); + if s1.len() == 2 { + let s2 = s1[1].split(']').collect::<Vec<&str>>(); + if s2.len() > 1 { + Some(format!("[{}]", s2[0])) + } else { + None + } + } else { + None + } } fn relative_element<T: Clone + PartialEq>(xs: &[T], x: &T, ri: i32) -> Option<T> { diff --git a/src/main.rs b/src/main.rs index a791f29..b18cb1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,35 +6,49 @@ mod space_repetition; mod sync; mod util; -use crate::util::event::Events; use anyhow::Result; +use clap::Parser; use std::path::PathBuf; -use structopt::StructOpt; -#[derive(StructOpt)] -#[structopt()] +#[derive(Parser)] +#[clap()] struct Opt { - #[structopt(long, default_value = "deck.deck")] + /// Path to the deck + #[clap(long, default_value = "deck.deck")] deck: String, + + /// Hide remaining card counts + #[clap(long)] + hide_remaining: bool, } fn main() -> Result<()> { - let deck_path = Opt::from_args().deck; + let args = Opt::parse(); + let deck_path = args.deck; let mut conn = db::init(db_path(&deck_path))?; let deck_name = deck::pp_from_path(&deck_path).unwrap_or_else(|| "Deck".to_string()); sync::run(&mut conn, &deck_path)?; + let deck_last_sync = util::time::seconds_since_unix_epoch()?; + + let mut term = gui::setup_terminal()?; - let mut term = gui::terminal()?; - let events = Events::new(); - match gui::start(&conn, &mut term, &events, &deck_name) { - Ok(()) => Ok(()), + match gui::start( + &mut conn, + &mut term, + &deck_path, + &deck_name, + deck_last_sync, + args.hide_remaining, + ) { + Ok(()) => (), Err(msg) => { // Show errors in TUI, otherwise they are hidden - gui::message::show(&mut term, &events, &deck_name, &format!("{msg}"), true)?; - Err(msg) + gui::message::show(&mut term, &deck_name, &format!("{msg}"), true)? } } + + gui::restore_terminal(&mut term) } fn db_path(deck_path: &str) -> String { diff --git a/src/model/difficulty.rs b/src/model/difficulty.rs index ea5a9ce..727ce4b 100644 --- a/src/model/difficulty.rs +++ b/src/model/difficulty.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Difficulty { Again, Hard, diff --git a/src/model/mod.rs b/src/model/mod.rs index 2dc1ab5..4df4c49 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -2,7 +2,7 @@ use crate::space_repetition; pub mod difficulty; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Line { pub part_1: Vec<String>, pub part_2: Vec<String>, diff --git a/src/sync.rs b/src/sync.rs index 3911d55..6e3d84b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -4,11 +4,12 @@ use crate::{ }; use anyhow::Result; use rusqlite::Connection; +use std::collections::HashMap; use std::collections::HashSet; pub fn run(conn: &mut Connection, deck_path: &str) -> Result<()> { let db_entries = db::all(conn)?; - let lines = deck::read(deck_path)?; + let lines = deck::read_file(deck_path)?; let Diff { new, deleted, @@ -29,29 +30,23 @@ struct Diff { } fn diff(db_entries: Vec<DbEntry>, lines: Vec<Line>) -> Diff { - let mut file_questions: HashSet<Question> = HashSet::new(); - let mut db_questions_not_deleted: HashSet<Question> = HashSet::new(); - let mut db_questions_deleted: HashSet<Question> = HashSet::new(); + let mut file_questions = HashMap::<String, Vec<String>>::new(); + let mut db_questions_not_deleted = HashSet::<Question>::new(); + let mut db_questions_deleted = HashSet::<Question>::new(); for Line { part_1, part_2 } in lines { - for question in part_1.clone() { - let mut responses = part_2.clone(); - responses.sort(); - file_questions.insert(Question { - question, - responses, - }); - } - for question in part_2 { - let mut responses = part_1.clone(); - responses.sort(); - file_questions.insert(Question { - question, - responses, - }); - } + insert(&mut file_questions, part_1.clone(), part_2.clone()); + insert(&mut file_questions, part_2, part_1); } + let file_questions: HashSet<Question> = file_questions + .iter() + .map(|(question, responses)| Question { + question: question.to_string(), + responses: responses.to_vec(), + }) + .collect(); + for DbEntry { question, mut responses, @@ -97,77 +92,120 @@ fn diff(db_entries: Vec<DbEntry>, lines: Vec<Line>) -> Diff { } } +fn insert(map: &mut HashMap<String, Vec<String>>, questions: Vec<String>, responses: Vec<String>) { + for question in questions { + let mut responses = responses.clone(); + responses.sort(); + match map.get_mut(&question) { + Some(existing_responses) => existing_responses.append(&mut responses), + None => { + map.insert(question, responses); + } + }; + } +} + #[cfg(test)] mod tests { - use super::*; + use super::{deck, DbEntry, Diff, Question}; + use std::collections::HashSet; #[test] - fn sync() { - let db_entries = vec![ - DbEntry { - question: "A".to_string(), - responses: vec!["A".to_string()], - deleted: None, - }, - DbEntry { - question: "B".to_string(), - responses: vec!["B".to_string()], - deleted: None, - }, - DbEntry { - question: "C".to_string(), - responses: vec!["C".to_string()], - deleted: Some(0), - }, - DbEntry { - question: "D".to_string(), - responses: vec!["D".to_string()], - deleted: Some(0), - }, - ]; - - let lines = vec![ - Line { - part_1: vec!["A".to_string()], - part_2: vec!["A".to_string()], - }, - Line { - part_1: vec!["C".to_string()], - part_2: vec!["C".to_string()], - }, - Line { - part_1: vec!["E".to_string()], - part_2: vec!["E".to_string()], - }, - ]; - - let Res { - new, - deleted, - undeleted, - } = sync(db_entries, lines); + fn test_added() { + let diff = deck_diff("- A : a", "- A : a\n- B : b"); - assert_eq!( - new, - vec!(Question { - question: "E".to_string(), - responses: vec!("E".to_string()) - }) - ); - assert_eq!( - deleted, - vec!(Question { - question: "B".to_string(), - responses: vec!("B".to_string()) - }) + has_questions(diff.new, vec![("B", vec!["b"]), ("b", vec!["B"])]); + assert!(diff.deleted.is_empty()); + assert!(diff.undeleted.is_empty()); + } + + #[test] + fn test_updated() { + let diff = deck_diff("- A : a1", "- A : a2"); + + has_questions(diff.new, vec![("A", vec!["a2"]), ("a2", vec!["A"])]); + has_questions(diff.deleted, vec![("A", vec!["a1"]), ("a1", vec!["A"])]); + assert!(diff.undeleted.is_empty()); + } + + #[test] + fn test_deleted() { + let diff = deck_diff("- A : a", ""); + + assert!(diff.new.is_empty()); + has_questions(diff.deleted, vec![("A", vec!["a"]), ("a", vec!["A"])]); + assert!(diff.undeleted.is_empty()); + } + + #[test] + fn test_undeleted() { + let db_entries = vec![DbEntry { + question: "A".to_string(), + responses: vec!["a".to_string()], + deleted: Some(0), + }]; + + let diff = super::diff(db_entries, deck::tests::read_string("- A : a").unwrap()); + + has_questions(diff.new, vec![("a", vec!["A"])]); + assert!(diff.deleted.is_empty()); + has_questions(diff.undeleted, vec![("A", vec!["a"])]); + } + #[test] + fn regroup_same_question() { + let diff = deck_diff("", "- A : a\n- A | B : b"); + + has_questions( + diff.new, + vec![ + ("A", vec!["a", "b"]), + ("B", vec!["b"]), + ("a", vec!["A"]), + ("b", vec!["A", "B"]), + ], ); + assert!(diff.deleted.is_empty()); + assert!(diff.undeleted.is_empty()); + } + + fn deck_diff(from: &str, to: &str) -> Diff { + super::diff(db_entries(from), deck::tests::read_string(to).unwrap()) + } + + fn has_questions(questions: Vec<Question>, xs: Vec<(&str, Vec<&str>)>) { assert_eq!( - undeleted, - vec!(Question { - question: "C".to_string(), - responses: vec!("C".to_string()) - }) - ); + to_set(questions), + HashSet::from_iter( + xs.iter() + .map(|(y, ys)| Question { + question: y.to_string(), + responses: ys.iter().map(|z| z.to_string()).collect::<Vec<_>>() + }) + .collect::<Vec<_>>() + ) + ) + } + + fn to_set<A: std::cmp::Eq + std::hash::Hash + std::clone::Clone>(xs: Vec<A>) -> HashSet<A> { + xs.iter().cloned().collect() + } + + fn db_entries(deck: &str) -> Vec<DbEntry> { + let lines = deck::tests::read_string(deck).unwrap(); + let diff = super::diff(vec![], lines); + diff.new + .iter() + .map( + |Question { + question, + responses, + }| DbEntry { + question: question.to_string(), + responses: responses.to_vec(), + deleted: None, + }, + ) + .collect() } } diff --git a/src/util/event.rs b/src/util/event.rs deleted file mode 100644 index 379df99..0000000 --- a/src/util/event.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::io; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -use termion::event::Key; -use termion::input::TermRead; - -pub enum Event<I> { - Input(I), - Tick, -} - -/// A small event handler that wrap termion input and tick events. Each event -/// type is handled in its own thread and returned to a common `Receiver` -pub struct Events { - rx: mpsc::Receiver<Event<Key>>, - input_handle: thread::JoinHandle<()>, - tick_handle: thread::JoinHandle<()>, -} - -#[derive(Debug, Clone, Copy)] -pub struct Config { - pub tick_rate: Duration, -} - -impl Default for Config { - fn default() -> Config { - Config { - tick_rate: Duration::from_millis(250), - } - } -} - -impl Events { - pub fn new() -> Events { - Events::with_config(Config::default()) - } - - pub fn with_config(config: Config) -> Events { - let (tx, rx) = mpsc::channel(); - let input_handle = { - let tx = tx.clone(); - thread::spawn(move || { - let stdin = io::stdin(); - for key in stdin.keys().flatten() { - if let Err(err) = tx.send(Event::Input(key)) { - eprintln!("{err}"); - return; - } - } - }) - }; - let tick_handle = { - thread::spawn(move || loop { - if let Err(err) = tx.send(Event::Tick) { - eprintln!("{err}"); - break; - } - thread::sleep(config.tick_rate); - }) - }; - Events { - rx, - input_handle, - tick_handle, - } - } - - pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> { - self.rx.recv() - } -} diff --git a/src/util/mod.rs b/src/util/mod.rs index c866e61..3444389 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,4 +1,3 @@ #[allow(dead_code)] -pub mod event; pub mod serialization; pub mod time; diff --git a/src/util/serialization.rs b/src/util/serialization.rs index 61b3a83..189a41a 100644 --- a/src/util/serialization.rs +++ b/src/util/serialization.rs @@ -8,3 +8,21 @@ pub fn line_to_words(line: &str) -> Vec<String> { pub fn words_to_line(words: &[String]) -> String { words.join(" | ") } + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_line_to_words() { + assert_eq!(line_to_words("a"), vec!("a")); + assert_eq!(line_to_words("a | b | c"), vec!("a", "b", "c")); + } + + #[test] + fn test_words_to_line() { + assert_eq!(words_to_line(&["a".to_string()]), "a"); + assert_eq!(words_to_line(&["a".to_string(), "b".to_string(), "c".to_string()]), "a | b | c"); + } +} |