diff --git a/Cargo.lock b/Cargo.lock index b73a052..ddebcd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy 0.7.35", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -17,6 +29,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -25,9 +54,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "bitflags" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" [[package]] name = "block" @@ -43,9 +72,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cairo-rs" @@ -72,9 +101,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.10" +version = "1.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" +checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c" dependencies = [ "shlex", ] @@ -144,18 +173,26 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + [[package]] name = "discord-presence" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3869559aec2ff5128b1bd18861692eba34b6f42265368dfde7ea0e802956dc8" +checksum = "806b031712b2d07bb83d077c1d66d1d7c4209e2b812cb5d35a6aadfc2670f91f" dependencies = [ "byteorder", "bytes", "cfg-if", "crossbeam-channel", "log", - "named_pipe", "num-derive", "num-traits", "parking_lot", @@ -163,15 +200,26 @@ dependencies = [ "quork", "serde", "serde_json", - "thiserror", + "thiserror 2.0.12", "uuid", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "endi" @@ -181,9 +229,21 @@ checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "field-offset" @@ -197,14 +257,23 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.35" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" dependencies = [ "crc32fast", "miniz_oxide", ] +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -270,9 +339,9 @@ dependencies = [ [[package]] name = "gdk-pixbuf" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6efc7705f7863d37b12ad6974cbb310d35d054f5108cdc1e69037742f573c4c" +checksum = "7563afd6ff0a221edfbb70a78add5075b8d9cb48e637a40a24c3ece3fea414d0" dependencies = [ "gdk-pixbuf-sys", "gio", @@ -295,9 +364,9 @@ dependencies = [ [[package]] name = "gdk4" -version = "0.9.5" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0196720118f880f71fe7da971eff58cc43a89c9cf73f46076b7cb1e60889b15" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -310,9 +379,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.9.5" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b0e1340bd15e7a78810cf39fed9e5d85f0a8f80b1d999d384ca17dcc452b60" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -333,7 +402,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -358,9 +439,9 @@ dependencies = [ [[package]] name = "gio" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a517657589a174be9f60c667f1fec8b7ac82ed5db4ebf56cf073a3b5955d8e2e" +checksum = "a4f00c70f8029d84ea7572dd0e1aaa79e5329667b4c17f329d79ffb1e6277487" dependencies = [ "futures-channel", "futures-core", @@ -375,9 +456,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.20.8" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8446d9b475730ebef81802c1738d972db42fde1c5a36a627ebc4d665fc87db04" +checksum = "160eb5250a26998c3e1b54e6a3d4ea15c6c7762a6062a19a7b63eff6e2b33f9e" dependencies = [ "glib-sys", "gobject-sys", @@ -388,9 +469,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f969edf089188d821a30cde713b6f9eb08b20c63fc2e584aba2892a7984a8cc0" +checksum = "707b819af8059ee5395a2de9f2317d87a53dbad8846a2f089f0bb44703f37686" dependencies = [ "bitflags", "futures-channel", @@ -422,9 +503,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b360ff0f90d71de99095f79c526a5888c9c92fc9ee1b19da06c6f5e75f0c2a53" +checksum = "a8928869a44cfdd1fccb17d6746e4ff82c8f82e41ce705aa026a52ca8dc3aefb" dependencies = [ "libc", "system-deps", @@ -432,9 +513,9 @@ dependencies = [ [[package]] name = "gobject-sys" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a56235e971a63bfd75abb13ef70064e1346388723422a68580d8a6fbac6423" +checksum = "c773a3cb38a419ad9c26c81d177d96b4b08980e8bdbbf32dace883e96e96e7e3" dependencies = [ "glib-sys", "libc", @@ -443,9 +524,9 @@ dependencies = [ [[package]] name = "graphene-rs" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f39d3bcd2e24fd9c2874a56f277b72c03e728de9bdc95a8d4ef4c962f10ced98" +checksum = "3cbc5911bfb32d68dcfa92c9510c462696c2f715548fcd7f3f1be424c739de19" dependencies = [ "glib", "graphene-sys", @@ -466,9 +547,9 @@ dependencies = [ [[package]] name = "gsk4" -version = "0.9.5" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b9188db0a6219e708b6b6e7225718e459def664023dbddb8395ca1486d8102" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" dependencies = [ "cairo-rs", "gdk4", @@ -481,9 +562,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.9.5" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca10fc65d68528a548efa3d8747934adcbe7058b73695c9a7f43a25352fce14" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -497,9 +578,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.9.5" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b697ff938136625f6acf75f01951220f47a45adcf0060ee55b4671cf734dac44" +checksum = "af1c491051f030994fd0cde6f3c44f3f5640210308cff1298c7673c47408091d" dependencies = [ "cairo-rs", "field-offset", @@ -530,9 +611,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.9.5" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af4b680cee5d2f786a2f91f1c77e95ecf2254522f0ca4edf3a2dce6cb35cecf" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -558,7 +639,7 @@ dependencies = [ "serde", "serde_json", "walkdir", - "zerocopy 0.8.14", + "zerocopy 0.8.24", "zvariant", ] @@ -574,12 +655,30 @@ dependencies = [ "quote", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -596,6 +695,145 @@ dependencies = [ "quote", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -617,26 +855,28 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "keypunch" version = "0.1.0" dependencies = [ + "anyhow", "discord-presence", "gettext-rs", + "graphene-rs", "gtk4", "gvdb-macros", "i18n-format", @@ -644,8 +884,11 @@ dependencies = [ "libadwaita", "rand", "rayon", + "refinery", + "rusqlite", "strum", "strum_macros", + "time", "unicode-segmentation", "unidecode", ] @@ -658,9 +901,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libadwaita" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8611ee9fb85e7606c362b513afcaf5b59853f79e4d98caaaf581d99465014247" +checksum = "500135d29c16aabf67baafd3e7741d48e8b8978ca98bac39e589165c8dc78191" dependencies = [ "gdk4", "gio", @@ -673,9 +916,9 @@ dependencies = [ [[package]] name = "libadwaita-sys" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b099a223560118d4d4fa04b6d23f3ea5b7171fe1d83dfb7e6b45b54cdfc83af9" +checksum = "6680988058c2558baf3f548a370e4e78da3bf7f08469daa822ac414842c912db" dependencies = [ "gdk4-sys", "gio-sys", @@ -689,9 +932,26 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "litrs" @@ -727,9 +987,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.25" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "malloc_buf" @@ -757,22 +1017,13 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.3" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430" dependencies = [ "adler2", ] -[[package]] -name = "named_pipe" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b" -dependencies = [ - "winapi", -] - [[package]] name = "nix" version = "0.29.0" @@ -785,6 +1036,12 @@ dependencies = [ "libc", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-derive" version = "0.4.2" @@ -805,6 +1062,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -834,11 +1100,17 @@ dependencies = [ "objc", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "pango" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e89bd74250a03a05cec047b43465469102af803be2bf5e5a1088f8b8455e087" +checksum = "6b1f5dc1b8cf9bc08bfc0843a04ee0fa2e78f1e1fa4b126844a383af4f25f0ec" dependencies = [ "gio", "glib", @@ -848,9 +1120,9 @@ dependencies = [ [[package]] name = "pango-sys" -version = "0.20.7" +version = "0.20.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71787e0019b499a5eda889279e4adb455a4f3fdd6870cd5ab7f4a5aa25df6699" +checksum = "0dbb9b751673bd8fe49eb78620547973a1e719ed431372122b20abd12445bab5" dependencies = [ "glib-sys", "gobject-sys", @@ -887,6 +1159,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -901,24 +1179,30 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.7.35", + "zerocopy 0.8.24", ] [[package]] name = "proc-macro-crate" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" dependencies = [ "toml_edit", ] @@ -947,18 +1231,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" -version = "0.37.2" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" dependencies = [ "memchr", "serde", @@ -973,7 +1257,7 @@ dependencies = [ "cfg-if", "nix", "quork-proc", - "thiserror", + "thiserror 2.0.12", "windows", ] @@ -992,13 +1276,19 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.38" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "rand" version = "0.8.5" @@ -1026,7 +1316,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -1051,13 +1341,57 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" dependencies = [ "bitflags", ] +[[package]] +name = "refinery" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba5d693abf62492c37268512ff35b77655d2e957ca53dab85bf993fe9172d15" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a83581f18c1a4c3a6ebd7a174bdc665f17f618d79f7edccb6a0ac67e660b319" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "serde", + "siphasher", + "thiserror 1.0.69", + "time", + "toml", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c225407d8e52ef8cf094393781ecda9a99d6544ec28d90a6915751de259264" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn", +] + [[package]] name = "regex" version = "1.11.1" @@ -1087,6 +1421,21 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "time", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1098,15 +1447,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "same-file" @@ -1125,24 +1474,24 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -1151,9 +1500,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.137" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -1176,6 +1525,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.9" @@ -1187,9 +1542,15 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "static_assertions" @@ -1218,15 +1579,26 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.96" +version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-deps" version = "7.0.3" @@ -1254,29 +1626,92 @@ checksum = "bc1ee6eef34f12f765cb94725905c6312b6610ab2b0940889cfe58dae7bc3c72" [[package]] name = "thiserror" -version = "2.0.11" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" -version = "0.8.19" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" dependencies = [ "serde", "serde_spanned", @@ -1295,9 +1730,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.22" +version = "0.22.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap", "serde", @@ -1308,9 +1743,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-segmentation" @@ -1324,21 +1759,56 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402bb19d8e03f1d1a7450e2bd613980869438e0666331be3e073089124aa1adc" +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" -version = "1.12.1" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom", + "getrandom 0.3.2", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1355,6 +1825,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1525,30 +2004,74 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.24" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36" dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ - "byteorder", "zerocopy-derive 0.7.35", ] [[package]] name = "zerocopy" -version = "0.8.14" +version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" dependencies = [ - "zerocopy-derive 0.8.14", + "zerocopy-derive 0.8.24", ] [[package]] @@ -1564,9 +2087,52 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.14" +version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", @@ -1575,9 +2141,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.2.0" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55e6b9b5f1361de2d5e7d9fd1ee5f6f7fcb6060618a1f82f3472f58f2b8d4be9" +checksum = "b2df9ee044893fcffbdc25de30546edef3e32341466811ca18421e3cd6c5a3ac" dependencies = [ "endi", "serde", @@ -1589,9 +2155,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.2.0" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "573a8dd76961957108b10f7a45bac6ab1ea3e9b7fe01aff88325dc57bb8f5c8b" +checksum = "74170caa85b8b84cc4935f2d56a57c7a15ea6185ccdd7eadb57e6edd90f94b2f" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1602,9 +2168,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd46446ea2a1f353bfda53e35f17633afa79f4fe290a611c94645c69fe96a50" +checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 4ae25ec..fbecaaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,16 +22,21 @@ version = "0.1.0" edition = "2021" [dependencies] +anyhow = "1.0.95" discord-presence = "1.5.0" # Locked to 0.7.0 because of https://github.com/hfiguiere/i18n-format/issues/1 gettext-rs = { version = "=0.7.0", features = ["gettext-system"] } +graphene = { package = "graphene-rs", version = "0.20.7" } gvdb-macros = "0.1.12" i18n-format = "0.2.0" include_dir = "0.7.3" rand = "0.8.5" rayon = "1.10.0" +refinery = { version = "0.8.14", features = ["rusqlite"] } +rusqlite = { version = "=0.31.0", features = ["bundled", "time"] } # Locked until Refinery is updated strum = "0.26.2" strum_macros = "0.26.2" +time = { version = "0.3.37", features = ["parsing", "local-offset"] } unicode-segmentation = "1.11.0" unidecode = "0.3.0" diff --git a/data/artwork/icon/dev.bragefuglseth.Keypunch.Source.svg b/data/artwork/icon/dev.bragefuglseth.Keypunch.Source.svg index 44d91db..2d3cc0b 100644 --- a/data/artwork/icon/dev.bragefuglseth.Keypunch.Source.svg +++ b/data/artwork/icon/dev.bragefuglseth.Keypunch.Source.svg @@ -9,7 +9,7 @@ height="152" id="svg11300" sodipodi:version="0.32" - inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)" + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" sodipodi:docname="dev.bragefuglseth.Keypunch.Source.svg" inkscape:output_extension="org.inkscape.output.svg.inkscape" version="1.0" @@ -1203,8 +1203,8 @@ inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="4" - inkscape:cx="-77.25" - inkscape:cy="126.125" + inkscape:cx="45.875" + inkscape:cy="64.5" inkscape:current-layer="g59" showgrid="false" inkscape:grid-bbox="true" diff --git a/data/resources/icons/scalable/actions/arrow-into-box-symbolic.svg b/data/resources/icons/scalable/actions/arrow-into-box-symbolic.svg new file mode 100644 index 0000000..975067b --- /dev/null +++ b/data/resources/icons/scalable/actions/arrow-into-box-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/resources/icons/scalable/actions/graph-symbolic.svg b/data/resources/icons/scalable/actions/graph-symbolic.svg new file mode 100644 index 0000000..d240e22 --- /dev/null +++ b/data/resources/icons/scalable/actions/graph-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/resources/icons/scalable/actions/lightbulb-symbolic.svg b/data/resources/icons/scalable/actions/lightbulb-symbolic.svg new file mode 100644 index 0000000..36337aa --- /dev/null +++ b/data/resources/icons/scalable/actions/lightbulb-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/resources/style-hc.css b/data/resources/style-hc.css new file mode 100644 index 0000000..51246e1 --- /dev/null +++ b/data/resources/style-hc.css @@ -0,0 +1,7 @@ +.line-chart-button > button { + box-shadow: none; +} + +.line-chart-button .line-chart-dot { + background-color: currentColor; +} diff --git a/data/resources/style.css b/data/resources/style.css index 764bbd6..15b714a 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -13,8 +13,8 @@ KpTextView > textview { font-variant-ligatures: none; } -KpResultsView .key-number { - font-size: 3.5rem; +.key-number { + font-size: 3.2rem; font-weight: 700; } @@ -49,3 +49,36 @@ window.hide-controls headerbar.test .end { border-radius: 9999px; } +.group-header { + padding: 6px 0px; +} + +.wpm-legend { + border-bottom: 2px solid var(--accent-bg-color); +} + +.accuracy-legend { + border-bottom: 2px dashed currentColor; + opacity: calc(var(--dim-opacity) - 0.2); +} + +.line-chart-dot { + border-radius: 50%; + background-color: var(--accent-bg-color); + transition: transform 200ms; +} + +.line-chart-button > button { + background: transparent; + padding: 0px; +} + +.line-chart-button > button:hover .line-chart-dot, +.line-chart-button > button:checked .line-chart-dot { + transform: scale(2); +} + +.line-chart-button { + --popover-bg-color: var(--accent-bg-color); + --popover-fg-color: var(--light-1); +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..dfecc85 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,277 @@ +use crate::typing_test_utils::*; +use anyhow::Result; +use gettextrs::gettext; +use i18n_format::i18n_fmt; +use rusqlite::Connection; +use std::fs; +use std::path::PathBuf; +use std::sync::LazyLock; +use time::{Date, Duration, Month, OffsetDateTime, Time}; + +pub struct ChartItem { + pub title: String, + pub time_index: usize, + pub wpm: f64, + pub accuracy: f64, +} + +pub struct PeriodSummary { + pub wpm: f64, + pub accuracy: f64, + pub finish_rate: f64, + pub practice_time: String, // TODO: store this as a time::Duration instead +} + +pub const DATABASE: LazyLock = LazyLock::new(|| { + let path = gtk::glib::user_data_dir().join("keypunch"); + + TypingStatsDb::setup(path).unwrap() +}); + +refinery::embed_migrations!("./src/migrations"); + +pub struct TypingStatsDb(Connection); + +impl TypingStatsDb { + pub fn setup(location: PathBuf) -> Result { + fs::create_dir_all(&location)?; + + let mut conn = Connection::open(&location.join("statistics.sqlite")).unwrap(); + migrations::runner().run(&mut conn).unwrap(); + + Ok(TypingStatsDb(conn)) + } + + pub fn push_summary(&self, summary: &TestSummary) -> Result<()> { + let (test_type, language, duration) = match summary.config { + TestConfig::Finite => ("Custom", None, None), + TestConfig::Generated { + difficulty, + language, + duration, + .. + } => { + let difficulty = match difficulty { + GeneratedTestDifficulty::Simple => "Simple", + GeneratedTestDifficulty::Advanced => "Advanced", + }; + ( + difficulty, + Some(language.to_string()), + Some(duration.to_string()), + ) + } + }; + + self.0.execute( + " + INSERT INTO tests ( + timestamp, + finished, + test_type, + language, + duration, + real_duration, + wpm, + accuracy + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + summary.start_timestamp, + summary.finished, + test_type, + language, + duration, + summary.real_duration.as_secs(), + summary.wpm, + summary.accuracy, + ), + )?; + Ok(()) + } + + // Returns the average WPM and accuracy at a given date + pub fn average_from_period( + &self, + start: OffsetDateTime, + end: OffsetDateTime, + ) -> rusqlite::Result<(f64, f64)> { + self.0.query_row( + "SELECT AVG(wpm), AVG(accuracy) + FROM tests + WHERE finished = TRUE + AND UNIXEPOCH(timestamp) BETWEEN ? AND ? + AND test_type IN ('Simple', 'Advanced')", + (start.unix_timestamp(), end.unix_timestamp()), + |row| Ok((row.get(0)?, row.get(1)?)), + ) + } + + pub fn get_past_month(&self) -> Option> { + let now = OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc()); + let today_start = now.replace_time(Time::MIDNIGHT); + let today_end = now.replace_time(Time::MAX); + + let mut month_data: Vec = (0..30) + .filter_map(|n| { + let start = today_start - Duration::days(29 - n); + let end = today_end - Duration::days(29 - n); + + if let Ok((wpm, accuracy)) = DATABASE.average_from_period(start, end) { + Some(ChartItem { + title: formatted_date(start.date()), + time_index: n as usize, + wpm, + accuracy, + }) + } else { + None + } + }) + .collect(); + + let &ChartItem { + time_index: time_offset, + .. + } = month_data.get(0)?; + + for item in month_data.iter_mut() { + item.time_index -= time_offset; + } + + Some(month_data) + } + + pub fn get_past_year(&self) -> Option> { + let now = OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc()); + + let mut month_data: Vec = (0..12) + .filter_map(|n| { + let mut start = now.replace_day(1).unwrap().replace_time(Time::MIDNIGHT); + + for _ in 0..(11 - n) { + let prev_month_length = start.month().previous().length(start.year()); + start -= Duration::days(prev_month_length as i64) + } + + let month_length = start.month().length(start.year()); + let end = start + .replace_day(month_length) + .unwrap() + .replace_time(Time::MAX); + + if let Ok((wpm, accuracy)) = DATABASE.average_from_period(start, end) { + Some(ChartItem { + title: formatted_month(start.date()), + time_index: n as usize, + wpm, + accuracy, + }) + } else { + None + } + }) + .collect(); + + let &ChartItem { + time_index: time_offset, + .. + } = month_data.get(0)?; + + for item in month_data.iter_mut() { + item.time_index -= time_offset; + } + + Some(month_data) + } + + pub fn last_month_summary(&self) -> Option { + let now = OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc()); + + let start = now.replace_time(Time::MIDNIGHT) - Duration::days(27); + let (wpm, accuracy) = self.average_from_period(start, now).ok()?; + + let (finish_rate, practice_time) = self + .0 + .query_row( + "SELECT SUM(finished), COUNT(*), SUM(real_duration) + FROM tests + WHERE UNIXEPOCH(timestamp) BETWEEN ? AND ? + AND test_type IN ('Simple', 'Advanced')", + (start.unix_timestamp(), now.unix_timestamp()), + |row| Ok((row.get::<_, f64>(0)? / row.get::<_, f64>(1)?, row.get::<_, i64>(2)?)), + ) + .ok()?; + + Some(PeriodSummary { + wpm, + accuracy, + finish_rate, + practice_time: human_readable_duration_short(Duration::seconds(practice_time)) + }) + } +} + +// TODO: move i18n stuff into separate file + +fn formatted_date(date: Date) -> String { + let day = date.day(); + + match date.month() { + // Translators: This is a date. The {} is replaced with a number. + Month::January => i18n_fmt! { i18n_fmt("January {}", day) }, + Month::February => i18n_fmt! { i18n_fmt("February {}", day) }, + Month::March => i18n_fmt! { i18n_fmt("March {}", day) }, + Month::April => i18n_fmt! { i18n_fmt("April {}", day) }, + Month::May => i18n_fmt! { i18n_fmt("May {}", day) }, + Month::June => i18n_fmt! { i18n_fmt("June {}", day) }, + Month::July => i18n_fmt! { i18n_fmt("July {}", day) }, + Month::August => i18n_fmt! { i18n_fmt("August {}", day) }, + Month::September => i18n_fmt! { i18n_fmt("September {}", day) }, + Month::October => i18n_fmt! { i18n_fmt("October {}", day) }, + Month::November => i18n_fmt! { i18n_fmt("November {}", day) }, + Month::December => i18n_fmt! { i18n_fmt("December {}", day) }, + } +} + +fn formatted_month(date: Date) -> String { + let year = date.year(); + + match date.month() { + // Translators: This is a month label for the "monthly" view in the statistics dialog. + // The {} is replaced with a year. + Month::January => i18n_fmt! { i18n_fmt("January {}", year) }, + Month::February => i18n_fmt! { i18n_fmt("February {}", year) }, + Month::March => i18n_fmt! { i18n_fmt("March {}", year) }, + Month::April => i18n_fmt! { i18n_fmt("April {}", year) }, + Month::May => i18n_fmt! { i18n_fmt("May {}", year) }, + Month::June => i18n_fmt! { i18n_fmt("June {}", year) }, + Month::July => i18n_fmt! { i18n_fmt("July {}", year) }, + Month::August => i18n_fmt! { i18n_fmt("August {}", year) }, + Month::September => i18n_fmt! { i18n_fmt("September {}", year) }, + Month::October => i18n_fmt! { i18n_fmt("October {}", year) }, + Month::November => i18n_fmt! { i18n_fmt("November {}", year) }, + Month::December => i18n_fmt! { i18n_fmt("December {}", year) }, + } +} + +pub fn human_readable_duration_short(duration: Duration) -> String { + let total_secs = duration.as_seconds_f32().floor() as u32; + + let minutes = total_secs / 60; + let secs = total_secs % 60; + + if minutes > 0 && secs > 0 { + // Translators: The `{}` blocks will be replaced with the number of minutes + // and seconds. Do not translate them! + i18n_fmt! { i18n_fmt("{}m {}s", minutes, secs) } + } else if minutes > 0 { + // Translators: The `{}` block will be replaced with the number of minutes. + // Do not translate it! + i18n_fmt! { i18n_nfmt("{} m", "{} m", minutes as u32, minutes) } + } else { + // Translators: The `{}` block will be replaced with the number of seconds. + // Do not translate it! + i18n_fmt! { i18n_nfmt("{} s", "{} s", secs as u32, secs) } + } +} diff --git a/src/widgets/window/ui_state.rs b/src/graph_layout.rs similarity index 100% rename from src/widgets/window/ui_state.rs rename to src/graph_layout.rs diff --git a/src/main.rs b/src/main.rs index 1af5565..1234ba4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod application; mod config; +mod database; mod discord_rpc; mod settings; mod text_generation; @@ -53,6 +54,8 @@ fn main() -> glib::ExitCode { // desktop features such as file opening and single-instance applications. let app = KpApplication::new(APP_ID, &gio::ApplicationFlags::empty()); + std::sync::LazyLock::force(&crate::database::DATABASE); + // Run the application. This function will block until the application // exits. Upon return, we have our exit code to return to the shell. (This // is the code you see when you do `echo $?` after running a command in a diff --git a/src/migrations/V1__initial.sql b/src/migrations/V1__initial.sql new file mode 100644 index 0000000..e384bad --- /dev/null +++ b/src/migrations/V1__initial.sql @@ -0,0 +1,13 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE tests ( + timestamp TEXT NOT NULL, + finished INTEGER NOT NULL, + test_type TEXT NOT NULL, + language TEXT, + duration TEXT, + real_duration INTEGER NOT NULL, + wpm INTEGER NOT NULL, + accuracy INTEGER NOT NULL +); +CREATE INDEX test_time_fin_lang ON tests(timestamp, finished, language); \ No newline at end of file diff --git a/src/typing_test_utils.rs b/src/typing_test_utils.rs index 174d984..f16521f 100644 --- a/src/typing_test_utils.rs +++ b/src/typing_test_utils.rs @@ -1,6 +1,6 @@ -/* session_enums.rs +/* typing_test_utils.rs * - * SPDX-FileCopyrightText: © 2024 Brage Fuglseth + * SPDX-FileCopyrightText: © 2024–2025 Brage Fuglseth * SPDX-License-Identifier: GPL-3.0-or-later * * This program is free software: you can redistribute it and/or modify @@ -23,8 +23,9 @@ use gettextrs::gettext; use gtk::gio; use gtk::prelude::*; use std::str::FromStr; -use std::time::{Duration, Instant, SystemTime}; +use std::time::{Duration, Instant}; use strum_macros::{Display as EnumDisplay, EnumIter, EnumString}; +use time::OffsetDateTime; #[derive(Clone, Copy, PartialEq, EnumString, EnumDisplay)] pub enum GeneratedTestDifficulty { @@ -120,7 +121,7 @@ impl PresenceState { pub struct TypingTest { pub config: TestConfig, pub start_instant: Instant, - pub start_system_time: SystemTime, + pub start_system_time: OffsetDateTime, } impl TypingTest { @@ -128,7 +129,7 @@ impl TypingTest { TypingTest { config, start_instant: Instant::now(), - start_system_time: SystemTime::now(), + start_system_time: OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc()), } } } @@ -138,19 +139,21 @@ pub struct TestSummary { pub config: TestConfig, pub real_duration: Duration, pub wpm: f64, - pub start_timestamp: SystemTime, + pub start_timestamp: OffsetDateTime, pub accuracy: f64, + pub finished: bool, } impl TestSummary { pub fn new( - start_timestamp: SystemTime, + start_timestamp: OffsetDateTime, start_instant: Instant, end_instant: Instant, config: TestConfig, original: &str, typed: &str, keystrokes: &Vec<(Instant, bool)>, + finished: bool, ) -> Self { let real_duration = end_instant.duration_since(start_instant); let correct_keystrokes = keystrokes.iter().filter(|(_, correct)| *correct).count(); @@ -162,6 +165,7 @@ impl TestSummary { wpm: calculate_wpm(real_duration, &original, &typed), start_timestamp, accuracy: correct_keystrokes as f64 / total_keystrokes as f64, + finished, } } } diff --git a/src/widgets/interactive_graph.rs b/src/widgets/interactive_graph.rs new file mode 100644 index 0000000..afc4e21 --- /dev/null +++ b/src/widgets/interactive_graph.rs @@ -0,0 +1,354 @@ +/* interactive_graph.rs + * + * SPDX-FileCopyrightText: © 2025 Brage Fuglseth + * SPDX-License-Identifier: GPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +use crate::widgets::line_chart::{CHART_HEIGHT, Y_BOUND_GROW_STEPS}; +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::{gdk, glib, gsk}; +use layout::KpInteractiveGraphLayout; +use layout_child::KpInteractiveGraphLayoutChild; +use std::cell::{Cell, RefCell}; + +mod layout_child { + use super::*; + + mod imp { + use super::*; + + #[derive(Debug, Default, glib::Properties)] + #[properties(wrapper_type = super::KpInteractiveGraphLayoutChild)] + pub struct KpInteractiveGraphLayoutChild { + #[property(get, set=Self::set_x_origin)] + pub x_origin: Cell, + #[property(get, set=Self::set_y_origin)] + pub y_origin: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for KpInteractiveGraphLayoutChild { + const NAME: &'static str = "KpInteractiveGraphLayoutChild"; + type Type = super::KpInteractiveGraphLayoutChild; + type ParentType = gtk::LayoutChild; + } + + #[glib::derived_properties] + impl ObjectImpl for KpInteractiveGraphLayoutChild {} + + impl LayoutChildImpl for KpInteractiveGraphLayoutChild {} + + impl KpInteractiveGraphLayoutChild { + pub fn set_x_origin(&self, x_origin: i32) { + self.x_origin.set(x_origin); + + self.obj().layout_manager().layout_changed(); + } + + pub fn set_y_origin(&self, y_origin: i32) { + self.y_origin.set(y_origin); + + self.obj().layout_manager().layout_changed(); + } + } + } + + glib::wrapper! { + pub struct KpInteractiveGraphLayoutChild(ObjectSubclass) + @extends gtk::LayoutChild, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; + } + + impl KpInteractiveGraphLayoutChild { + pub fn new(layout_manager: >k::LayoutManager, child: >k::Widget) -> Self { + glib::Object::builder() + .property("layout-manager", &*layout_manager) + .property("child-widget", &*child) + .build() + } + } +} + +mod layout { + use super::*; + + mod imp { + use super::*; + + #[derive(Debug, Default, glib::Properties)] + #[properties(wrapper_type = super::KpInteractiveGraphLayout)] + pub struct KpInteractiveGraphLayout { + #[property(get, set)] + x_bound: Cell, + #[property(get, set)] + y_bound: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for KpInteractiveGraphLayout { + const NAME: &'static str = "KpInteractiveGraphLayout"; + type Type = super::KpInteractiveGraphLayout; + type ParentType = gtk::LayoutManager; + } + + #[glib::derived_properties] + impl ObjectImpl for KpInteractiveGraphLayout {} + impl LayoutManagerImpl for KpInteractiveGraphLayout { + fn measure( + &self, + _widget: >k::Widget, + orientation: gtk::Orientation, + _for_size: i32, + ) -> (i32, i32, i32, i32) { + match orientation { + gtk::Orientation::Vertical => { + (CHART_HEIGHT as i32, CHART_HEIGHT as i32, -1, -1) + } + gtk::Orientation::Horizontal => (100, 100, -1, -1), + _ => unreachable!(), + } + } + + fn allocate(&self, widget: >k::Widget, width: i32, height: i32, _baseline: i32) { + let mut child = widget + .first_child() + .expect("graph has at least one datapoint"); + loop { + let (req, _) = child.preferred_size(); + + let layout_child: KpInteractiveGraphLayoutChild = + self.obj().layout_child(&child).downcast().unwrap(); + + let (mut x, mut y) = graph_to_widget_coords( + layout_child.x_origin(), + layout_child.y_origin(), + self.x_bound.get(), + self.y_bound.get(), + width, + height, + ); + + x -= req.width() / 2; + y -= req.height() / 2; + + child.size_allocate(>k::Allocation::new(x, y, req.width(), req.height()), -1); + + if let Some(next_child) = child.next_sibling() { + child = next_child; + } else { + break; + } + } + } + + fn create_layout_child( + &self, + _container: >k::Widget, + child: >k::Widget, + ) -> gtk::LayoutChild { + KpInteractiveGraphLayoutChild::new(&*self.obj().upcast_ref(), &child).upcast() + } + } + } + + glib::wrapper! { + pub struct KpInteractiveGraphLayout(ObjectSubclass) + @extends gtk::LayoutManager; + } +} + +mod imp { + use super::*; + + #[derive(Default)] + pub struct KpInteractiveGraph { + pub accuracy_datapoints: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for KpInteractiveGraph { + const NAME: &'static str = "KpInteractiveGraph"; + type Type = super::KpInteractiveGraph; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.set_layout_manager_type::(); + } + } + + impl ObjectImpl for KpInteractiveGraph { + fn constructed(&self) { + self.parent_constructed(); + + self.obj().set_valign(gtk::Align::End); + } + + fn dispose(&self) { + while let Some(child) = self.obj().first_child() { + child.unparent(); + } + } + } + + impl WidgetImpl for KpInteractiveGraph { + fn snapshot(&self, snapshot: >k::Snapshot) { + let width = self.obj().width(); + let height = self.obj().height(); + + let style_manager = adw::StyleManager::default(); + let accent = style_manager.accent_color_rgba(); + let dimmed = match (style_manager.is_dark(), style_manager.is_high_contrast()) { + (false, false) => gdk::RGBA::new(0.6, 0.6, 0.6, 1.), + (false, true) => gdk::RGBA::new(0.2, 0.2, 0.2, 1.), + (true, false) => gdk::RGBA::new(0.4, 0.4, 0.4, 1.), + (true, true) => gdk::RGBA::new(0.8, 0.8, 0.8, 1.), + }; + + let mut child = self + .obj() + .first_child() + .expect("graph has at least one datapoint"); + + loop { + if let Some(next_child) = child.next_sibling() { + let p1 = child + .compute_bounds(&*self.obj()) + .expect("child is allocated") + .center(); + let p2 = next_child + .compute_bounds(&*self.obj()) + .expect("child is allocated") + .center(); + + let path = gsk::PathBuilder::new(); + path.move_to(p1.x(), p1.y()); + path.line_to(p2.x(), p2.y()); + + let path = path.to_path(); + + let stroke = gsk::Stroke::new(2.); + + snapshot.append_stroke(&path, &stroke, &accent); + + self.obj().snapshot_child(&child, &*snapshot); + child = next_child + } else { + self.obj().snapshot_child(&child, &*snapshot); + break; + } + } + + let layout_manager = self + .obj() + .layout_manager() + .unwrap() + .downcast::() + .unwrap(); + + let path = gsk::PathBuilder::new(); + + let (start_time, start_accuracy) = + self.accuracy_datapoints.borrow().get(0).unwrap().clone(); + let (start_x, start_y) = graph_to_widget_coords( + start_time as i32, + (start_accuracy * 100.).floor() as i32, + layout_manager.x_bound(), + 100, + width, + height, + ); + path.move_to(start_x as f32, start_y as f32); + + for (time_index, accuracy) in self.accuracy_datapoints.borrow().iter().skip(1) { + let (x, y) = graph_to_widget_coords( + *time_index as i32, + (*accuracy * 100.).floor() as i32, + layout_manager.x_bound(), + 100, + width, + height, + ); + + path.line_to(x as f32, y as f32); + } + + let path = path.to_path(); + + let stroke = gsk::Stroke::new(2.); + stroke.set_dash(&[4., 2.]); + + snapshot.append_stroke(&path, &stroke, &dimmed); + } + } +} + +glib::wrapper! { + pub struct KpInteractiveGraph(ObjectSubclass) + @extends gtk::Widget; +} + +impl KpInteractiveGraph { + pub fn new() -> Self { + glib::Object::builder().build() + } + + pub fn insert_with_coordinates(&self, widget: &impl IsA, x: i32, y: i32) { + widget.set_parent(&*self); + + let layout_manager = self + .layout_manager() + .expect("layout manager was set at class init") + .downcast::() + .unwrap(); + + let layout_child = layout_manager + .layout_child(&*widget) + .downcast::() + .unwrap(); + + layout_child.set_x_origin(x); + layout_child.set_y_origin(y); + + layout_manager.set_x_bound(layout_manager.x_bound().max(x)); + layout_manager.set_y_bound( + layout_manager + .y_bound() + .max(((y / Y_BOUND_GROW_STEPS as i32) + 1) * Y_BOUND_GROW_STEPS as i32), + ); + } + + pub fn insert_accuracy_datapoint(&self, time_index: usize, accuracy: f64) { + self.imp() + .accuracy_datapoints + .borrow_mut() + .push((time_index, accuracy)); + } +} + +fn graph_to_widget_coords( + x: i32, + y: i32, + x_bound: i32, + y_bound: i32, + width: i32, + height: i32, +) -> (i32, i32) { + let trans_x = x as f64 * (width as f64 / x_bound as f64); + let trans_y = height as f64 - (y as f64 * (height as f64 / y_bound as f64)); + + (trans_x.floor() as i32, trans_y.floor() as i32) +} diff --git a/src/widgets/line_chart.rs b/src/widgets/line_chart.rs new file mode 100644 index 0000000..a80c419 --- /dev/null +++ b/src/widgets/line_chart.rs @@ -0,0 +1,254 @@ +/* line_chart.rs + * + * SPDX-FileCopyrightText: © 2025 Brage Fuglseth + * SPDX-License-Identifier: GPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +use crate::database::ChartItem; +use crate::widgets::KpInteractiveGraph; +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib; +use std::cell::{OnceCell, RefCell}; + +// The Y bound (y boundary) is the tallest logical y-height shown on the diagram +// (WPM units, not pixels). +// It grows in multiples of Y_BOUND_GROW_STEPS at a time. +pub const Y_BOUND_GROW_STEPS: usize = 50; +pub const RULER_COUNT: usize = 6; +pub const CHART_HEIGHT: usize = 200; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct KpLineChart { + main_box: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for KpLineChart { + const NAME: &'static str = "KpLineChart"; + type Type = super::KpLineChart; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.set_layout_manager_type::(); + } + } + + impl ObjectImpl for KpLineChart { + fn dispose(&self) { + while let Some(child) = self.obj().first_child() { + child.unparent(); + } + } + } + + impl WidgetImpl for KpLineChart {} + + impl KpLineChart { + fn main_box(&self) -> >k::Box { + self.main_box + .get() + .expect("main box initialized during construction") + } + } +} + +glib::wrapper! { + pub struct KpLineChart(ObjectSubclass) + @extends gtk::Widget; +} + +impl KpLineChart { + pub fn new(data: &Vec) -> Self { + let obj = glib::Object::new::(); + + let main_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + main_box.set_parent(&obj); + + let accuracy_legend = adw::Bin::builder() + .css_classes(["accuracy-legend"]) + .width_request(34) + .halign(gtk::Align::Start) + .build(); + let accuracy_header = gtk::Label::builder() + .label("Accuracy") + .xalign(0.) + .hexpand(true) + .css_classes(["caption", "dimmed"]) + .build(); + let accuracy_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + accuracy_box.append(&accuracy_legend); + accuracy_box.append(&accuracy_header); + + let wpm_legend = adw::Bin::builder() + .css_classes(["wpm-legend"]) + .width_request(34) + .halign(gtk::Align::End) + .build(); + let wpm_header = gtk::Label::builder() + .label("Words per Minute") + .xalign(1.) + .hexpand(true) + .css_classes(["caption", "accent"]) + .build(); + let wpm_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + wpm_box.append(&wpm_legend); + wpm_box.append(&wpm_header); + + let header_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_start(12) + .margin_end(12) + .margin_top(12) + .margin_bottom(12) + .build(); + + header_box.append(&accuracy_box); + header_box.append(&wpm_box); + + main_box.append(&header_box); + + let highest_y_val = data + .iter() + .max_by(|ChartItem { wpm: a, .. }, ChartItem { wpm: b, .. }| { + a.partial_cmp(&b).expect("values are comparable") + }) + .map(|item| item.wpm) + .unwrap_or(0.) + .floor() as usize; + + let y_bound = ((highest_y_val / Y_BOUND_GROW_STEPS) + 1) * Y_BOUND_GROW_STEPS; + + let ruler_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + + let ruler_height = (CHART_HEIGHT / (RULER_COUNT - 1)) as i32; + + for i in 0..RULER_COUNT { + let percentage = (i as f64 / (RULER_COUNT - 1) as f64 * 100.).floor() as usize; + + let acc_label = gtk::Label::builder() + // TODO: Make translatable + .label(&format!("{percentage}%")) + .xalign(0.) + .margin_start(12) + .halign(gtk::Align::Fill) + .hexpand(true) + .css_classes(["dimmed", "caption"]) + .build(); + + let wpm_label = gtk::Label::builder() + .label( + (((y_bound as f64 / (RULER_COUNT - 1) as f64) * i as f64).floor() as usize) + .to_string(), + ) + .xalign(1.) + .margin_end(12) + .css_classes(["accent", "caption"]) + .build(); + + let ylabel_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .halign(gtk::Align::Fill) + .margin_bottom(2) + .build(); + ylabel_box.append(&acc_label); + ylabel_box.append(&wpm_label); + + let separator = gtk::Separator::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + + let ruler = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .valign(gtk::Align::End) + .build(); + + ruler.append(&ylabel_box); + ruler.append(&separator); + + let bin = adw::Bin::builder().child(&ruler).build(); + + if i != (RULER_COUNT - 1) { + bin.set_height_request(ruler_height); + } + + ruler_box.prepend(&bin); + } + + let interactive_graph = KpInteractiveGraph::new(); + interactive_graph.set_margin_start(54); + interactive_graph.set_margin_end(54); + + for item in data.iter() { + let dot = adw::Bin::builder() + .width_request(6) + .height_request(6) + .valign(gtk::Align::Center) + .halign(gtk::Align::Center) + .css_classes(["line-chart-dot"]) + .build(); + + let popover_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + popover_box.append( + >k::Label::builder() + .label(&item.title) + .css_classes(["heading"]) + .build(), + ); + + popover_box.append( + >k::Label::builder() + .label(&format!( + "{:.0} words per minute\n{}% accuracy", + &item.wpm.floor(), + (item.accuracy * 100.).floor() + )) + .justify(gtk::Justification::Center) + .use_markup(true) + .build(), + ); + + let popover = gtk::Popover::builder().child(&popover_box).build(); + + let btn = gtk::MenuButton::builder() + .css_classes(["line-chart-button"]) + .direction(gtk::ArrowType::Up) + .child(&dot) + .popover(&popover) + .build(); + + interactive_graph.insert_with_coordinates( + &btn, + item.time_index as i32, + item.wpm.floor() as i32, + ); + + interactive_graph.insert_accuracy_datapoint(item.time_index, item.accuracy); + } + + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&ruler_box)); + overlay.add_overlay(&interactive_graph); + + main_box.append(&overlay); + + obj + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 110c58e..c037876 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1,6 +1,6 @@ /* mod.rs * - * SPDX-FileCopyrightText: © 2024 Brage Fuglseth + * SPDX-FileCopyrightText: © 2024–2025 Brage Fuglseth * SPDX-License-Identifier: GPL-3.0-or-later * * This program is free software: you can redistribute it and/or modify @@ -18,13 +18,19 @@ */ mod custom_text_dialog; +mod interactive_graph; +mod line_chart; mod results_view; +mod statistics_dialog; mod text_language_dialog; mod text_view; mod window; pub use custom_text_dialog::KpCustomTextDialog; +pub use interactive_graph::KpInteractiveGraph; +pub use line_chart::KpLineChart; pub use results_view::KpResultsView; +pub use statistics_dialog::KpStatisticsDialog; pub use text_language_dialog::KpTextLanguageDialog; pub use text_view::KpTextView; pub use window::KpWindow; diff --git a/src/widgets/statistics_dialog.blp b/src/widgets/statistics_dialog.blp new file mode 100644 index 0000000..4bf1794 --- /dev/null +++ b/src/widgets/statistics_dialog.blp @@ -0,0 +1,475 @@ +/* statistics_dialog.blp + * + * SPDX-FileCopyrightText: © 2025 Brage Fuglseth + * SPDX-License-Identifier: GPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +using Gtk 4.0; +using Adw 1; + +template $KpStatisticsDialog: Adw.Dialog { + title: _("Statistics"); + content-width: 700; + + Stack stack { + StackPage { + name: "no_data"; + + child: Adw.ToolbarView { + [top] + Adw.HeaderBar {} + + content: Adw.StatusPage { + icon-name: "graph-symbolic"; + title: _("Keep on Typing"); + description: _("Typing data from at least 2 different days is required to view statistics. If you have statistics from earlier, you can load them."); + + Button { + halign: center; + + Adw.ButtonContent { + icon-name: "arrow-into-box-symbolic"; + label: _("Load Statistics"); + } + + styles [ + "pill" + ] + } + }; + }; + } + + StackPage { + name: "statistics"; + + child: Adw.ToolbarView { + [top] + Adw.HeaderBar header_bar {} + + content: ScrolledWindow scrolled_window { + hscrollbar-policy: never; + propagate-natural-height: true; + vexpand: true; + + Adw.Clamp { + Box { + orientation: vertical; + margin-start: 12; + margin-end: 12; + margin-bottom: 12; + + Label { + label: _("Statistics"); + margin-bottom: 24; + + styles [ + "title-1" + ] + } + + // Graph + Box { + orientation: vertical; + + styles [ + "card" + ] + + Adw.InlineViewSwitcher { + stack: graph_stack; + halign: center; + homogeneous: true; + margin-top: 12; + } + + Adw.ViewStack graph_stack { + enable-transitions: true; + margin-bottom: 12; + + Adw.ViewStackPage { + name: "daily"; + // Translators: label for viewing average typing speed and accuracy for each *day* in the last *month* (30 days) + title: _("Daily"); + + child: Adw.Bin daily_bin {}; + } + + Adw.ViewStackPage { + name: "monthly"; + // Translators: label for viewing average typing speed and accuracy for each *month* in the last *year* + title: _("Monthly"); + + child: Adw.Bin monthly_bin {}; + } + } + } + + // Last Month + Box { + margin-top: 18; + halign: fill; + height-request: 46; + + styles [ "group-header" ] + + Label { + label: _("Last Month"); + xalign: 0; + hexpand: true; + + styles [ + "heading" + ] + } + + MenuButton { + icon-name: "lightbulb-symbolic"; + direction: up; + valign: center; + + styles [ "flat" ] + + popover: Popover { + Label { + label: _("Average speed and accuracy for generated, non-custom sessions, the share of tests that have been finished without cancelling, and the total amount of time spent on finished tests, all during the last month."); + wrap: true; + max-width-chars: 45; + justify: center; + } + }; + } + } + + Adw.WrapBox last_month { + child-spacing: 12; + line-spacing: 12; + justify: fill; + justify-last-line: true; + + Adw.Bin { + styles [ + "card", + ] + Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 18; + margin-start: 18; + margin-end: 18; + + Label month_wpm_label { + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "Words per Minute"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + + Adw.Bin { + styles [ + "card", + ] + Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 18; + margin-start: 18; + margin-end: 18; + + Label month_accuracy_label { + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "Accuracy"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + + Adw.Bin { + styles [ + "card", + ] + Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 18; + margin-start: 18; + margin-end: 18; + + Label month_finish_rate_label { + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "Finish Rate"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + + Adw.Bin { + styles [ + "card", + ] + Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 18; + margin-start: 18; + margin-end: 18; + + Label month_practice_time_label { + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "Practice Time"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + } + + // PRs + Label { + label: _("Personal Records"); + xalign: 0; + margin-top: 18; + height-request: 46; + + styles [ + "heading", + ] + } + + Adw.WrapBox records_box { + child-spacing: 12; + line-spacing: 12; + justify: fill; + justify-last-line: true; + + Adw.Bin { + styles [ + "card", + ] + Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 18; + margin-start: 18; + margin-end: 18; + + Label { + label: "115"; + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "15 seconds"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + + Adw.Bin { + styles [ + "card", + ] + Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 18; + margin-start: 18; + margin-end: 18; + + Label { + label: "103"; + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "30 seconds"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + + Adw.Bin { + styles [ + "card", + ] + Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 18; + margin-start: 18; + margin-end: 18; + + Label { + label: "92"; + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "1 minute"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + + Adw.Bin { + styles [ + "card", + ] + Box { + orientation: vertical; + margin-top: 12; + margin-bottom: 18; + margin-start: 18; + margin-end: 18; + + Label { + label: "53"; + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "2 minutes"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + + } + + // Data Management + Label { + label: _("Data Management"); + xalign: 0; + hexpand: true; + height-request: 46; + margin-top: 18; + + styles [ + "heading" + ] + } + + Gtk.ListBox { + selection-mode: none; + + styles [ "boxed-list" ] + + Adw.ButtonRow { + start-icon-name: "arrow-into-box-symbolic"; + title: _("Load Statistics"); + } + Adw.ButtonRow { + end-icon-name: "go-next"; + title: _("Export Statistics"); + } + } + + Gtk.ListBox { + selection-mode: none; + margin-top: 12; + + styles [ "boxed-list" ] + + Adw.ButtonRow { + title: _("Clear Statistics"); + + styles [ "destructive-action" ] + } + } + + } + } + }; + }; + } + } +} \ No newline at end of file diff --git a/src/widgets/statistics_dialog.rs b/src/widgets/statistics_dialog.rs new file mode 100644 index 0000000..ec60754 --- /dev/null +++ b/src/widgets/statistics_dialog.rs @@ -0,0 +1,145 @@ +/* statistics_dialog.rs + * + * SPDX-FileCopyrightText: © 2025 Brage Fuglseth + * SPDX-License-Identifier: GPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +use crate::database::DATABASE; +use crate::database::{ChartItem, PeriodSummary}; +use crate::widgets::KpLineChart; +use anyhow::Result; +use adw::prelude::*; +use adw::subclass::prelude::*; +use glib::subclass::Signal; +use gtk::glib; +use i18n_format::i18n_fmt; +use std::sync::OnceLock; +use time::{Duration, OffsetDateTime, Time}; + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(file = "src/widgets/statistics_dialog.blp")] + pub struct KpStatisticsDialog { + #[template_child] + stack: TemplateChild, + #[template_child] + header_bar: TemplateChild, + #[template_child] + scrolled_window: TemplateChild, + #[template_child] + daily_bin: TemplateChild, + #[template_child] + monthly_bin: TemplateChild, + #[template_child] + month_wpm_label: TemplateChild, + #[template_child] + month_accuracy_label: TemplateChild, + #[template_child] + month_finish_rate_label: TemplateChild, + #[template_child] + month_practice_time_label: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for KpStatisticsDialog { + const NAME: &'static str = "KpStatisticsDialog"; + type Type = super::KpStatisticsDialog; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for KpStatisticsDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("save") + .param_types([str::static_type()]) + .build(), + Signal::builder("discard") + .param_types([str::static_type()]) + .build(), + ] + }) + } + + fn constructed(&self) { + self.parent_constructed(); + + let header_bar = self.header_bar.get(); + self.scrolled_window + .vadjustment() + .bind_property("value", &header_bar, "show-title") + .transform_to(|_, scroll_position: f64| Some(scroll_position > 0.)) + .sync_create() + .build(); + + self.stack.set_visible_child_name(if self.populate() { + "statistics" + } else { + "no_data" + }); + } + } + impl WidgetImpl for KpStatisticsDialog {} + impl AdwDialogImpl for KpStatisticsDialog {} + impl KpStatisticsDialog { + fn populate(&self) -> bool { + let Some(month_data) = DATABASE.get_past_month() else { return false; }; + let month_stats_chart = KpLineChart::new(&month_data); + self.daily_bin.set_child(Some(&month_stats_chart)); + + let Some(year_data) = DATABASE.get_past_year() else { return false; }; + let year_stats_chart = KpLineChart::new(&year_data); + self.monthly_bin.set_child(Some(&year_stats_chart)); + + let Some(month_summary) = DATABASE.last_month_summary() else { return false; }; + + self.month_wpm_label + .set_label(&month_summary.wpm.floor().to_string()); + self.month_accuracy_label + .set_label(&i18n_fmt! { i18n_fmt("{}%", (month_summary.accuracy * 100.).floor()) }); + self.month_finish_rate_label.set_label( + &i18n_fmt! { i18n_fmt("{}%", (month_summary.finish_rate * 100.).floor()) }, + ); + self.month_practice_time_label.set_label( + &month_summary.practice_time, + ); + + true + } + } +} + +glib::wrapper! { + pub struct KpStatisticsDialog(ObjectSubclass) + @extends gtk::Widget, adw::Dialog; +} + +impl KpStatisticsDialog { + pub fn new() -> Self { + glib::Object::new() + } +} diff --git a/src/widgets/window.blp b/src/widgets/window.blp index 2282641..e7cd206 100644 --- a/src/widgets/window.blp +++ b/src/widgets/window.blp @@ -47,7 +47,7 @@ template $KpWindow: Adw.ApplicationWindow { [start] Adw.LayoutSlot { - id: "stop_button"; + id: "header_bar_start"; } [title] @@ -98,7 +98,7 @@ template $KpWindow: Adw.ApplicationWindow { [start] Adw.LayoutSlot { - id: "stop_button"; + id: "header_bar_start"; } [end] @@ -144,12 +144,29 @@ template $KpWindow: Adw.ApplicationWindow { }; } - [stop_button] - Button stop_button { - icon-name: "arrow-circular-top-right-symbolic"; - tooltip-text: _("Restart"); + [header_bar_start] + Stack header_bar_start { + StackPage { + name: "statistics_button"; + + child: Button statistics_button { + icon-name: "graph-symbolic"; + tooltip-text: _("Statistics"); + + action-name: "win.statistics-dialog"; + }; + } - clicked => $ready() swapped; + StackPage { + name: "stop_button"; + + child: Button stop_button { + icon-name: "arrow-circular-top-right-symbolic"; + tooltip-text: _("Restart"); + + action-name: "win.cancel-test"; + }; + } } [test_status] diff --git a/src/widgets/window.rs b/src/widgets/window.rs index fcdcc99..a2dbe3a 100644 --- a/src/widgets/window.rs +++ b/src/widgets/window.rs @@ -52,6 +52,10 @@ mod imp { #[template_child] pub custom_button: TemplateChild, #[template_child] + pub header_bar_start: TemplateChild, + #[template_child] + pub statistics_button: TemplateChild, + #[template_child] pub stop_button: TemplateChild, #[template_child] pub status_stack: TemplateChild, @@ -106,7 +110,11 @@ mod imp { }); klass.install_action("win.cancel-test", None, move |window, _, _| { - window.imp().ready(); + window.imp().cancel_test(); + }); + + klass.install_action("win.statistics-dialog", None, move |window, _, _| { + window.imp().show_statistics_dialog(); }); } diff --git a/src/widgets/window/focus.rs b/src/widgets/window/focus.rs index cacd499..1356388 100644 --- a/src/widgets/window/focus.rs +++ b/src/widgets/window/focus.rs @@ -63,7 +63,7 @@ impl imp::KpWindow { move || { if !imp.text_view_focused() && imp.obj().visible_dialog().is_none() - && imp.main_stack.visible_child_name().unwrap() == "session" + && imp.main_stack.visible_child_name().unwrap() == "test" { bottom_stack.set_visible_child(&focus_button); text_view.add_css_class("unfocused"); diff --git a/src/widgets/window/typing_test.rs b/src/widgets/window/typing_test.rs index 61f2c6c..4d279db 100644 --- a/src/widgets/window/typing_test.rs +++ b/src/widgets/window/typing_test.rs @@ -18,10 +18,11 @@ */ use super::*; +use crate::database::DATABASE; use crate::text_generation; use crate::text_utils::{process_custom_text, GraphemeState}; use crate::typing_test_utils::TestSummary; -use crate::widgets::{KpCustomTextDialog, KpTextLanguageDialog}; +use crate::widgets::{KpCustomTextDialog, KpStatisticsDialog, KpTextLanguageDialog}; use gettextrs::gettext; use glib::ControlFlow; use i18n_format::i18n_fmt; @@ -257,10 +258,11 @@ impl imp::KpWindow { self.text_view.set_accepts_input(true); self.main_stack.set_visible_child_name("test"); self.status_stack.set_visible_child_name("ready"); + self.header_bar_start + .set_visible_child_name("statistics_button"); self.bottom_stack .set_visible_child(&self.just_start_typing.get()); self.menu_button.set_visible(true); - self.stop_button.set_visible(false); self.text_view.reset(); self.focus_text_view(); @@ -297,12 +299,6 @@ impl imp::KpWindow { self.bottom_stack .set_visible_child(&self.bottom_stack_empty.get()); - // Ugly hack to stop the stop button from "flashing" when starting a test: - // Make it visible with 0 opacity, and set the opacity to 1 after the 200ms - // crossfade effect has finished - self.stop_button.set_opacity(0.); - self.stop_button.set_visible(true); - glib::timeout_add_local_once( Duration::from_millis(200), glib::clone!( @@ -311,7 +307,7 @@ impl imp::KpWindow { move || { if imp.is_running() { imp.menu_button.set_visible(false); - imp.stop_button.set_opacity(1.); + imp.header_bar_start.set_visible_child_name("stop_button"); } } ), @@ -341,8 +337,8 @@ impl imp::KpWindow { return; }; - self.end_test(); - self.show_results_view(test, Instant::now()); + let summary = self.end_test(test, true); + self.show_results_view(summary); let config = test.config; @@ -354,11 +350,11 @@ impl imp::KpWindow { } pub(super) fn frustration_relief(&self) { - if !self.is_running() { + let Some(test) = self.current_test.get() else { return; - } + }; - self.end_test(); + self.end_test(test, false); self.main_stack.set_visible_child_name("frustration-relief"); // Avoid continue button being activated from a keypress immediately @@ -375,8 +371,47 @@ impl imp::KpWindow { ), ); } + #[template_callback] + pub(super) fn cancel_test(&self) { + let Some(test) = self.current_test.get() else { + return; + }; + self.end_test(test, false); + self.ready(); + } + + pub(super) fn end_test(&self, test: TypingTest, finished: bool) -> TestSummary { + let end_instant = Instant::now(); + + let TypingTest { + config, + start_instant, + start_system_time, + } = test; + + let original_text = match config { + TestConfig::Generated { .. } => self.text_view.original_text(), + TestConfig::Finite => process_custom_text(&self.text_view.original_text()), + }; + let typed_text = self.text_view.typed_text(); + + let keystrokes = self.text_view.keystrokes(); + + let summary = TestSummary::new( + start_system_time, + start_instant, + end_instant, + config, + &original_text, + &typed_text, + &keystrokes, + finished, + ); + + if let Err(e) = DATABASE.push_summary(&summary) { + println!("Database error: {e}"); + } - pub(super) fn end_test(&self) { self.current_test.set(None); self.text_view.set_running(false); self.text_view.set_accepts_input(false); @@ -386,6 +421,8 @@ impl imp::KpWindow { self.obj().action_set_enabled("win.cancel-test", false); self.end_existing_inhibit(); + + summary } pub(super) fn hide_cursor(&self) { @@ -458,6 +495,12 @@ impl imp::KpWindow { .set_activity(config, PresenceState::Ready); } + pub(super) fn show_statistics_dialog(&self) { + let dialog = KpStatisticsDialog::new(); + + dialog.present(Some(self.obj().upcast_ref::())); + } + pub(super) fn show_text_language_dialog(&self) { if self.is_running() || self.obj().visible_dialog().is_some() { return; @@ -614,35 +657,11 @@ impl imp::KpWindow { self.status_label.set_label(&text); } - pub(super) fn show_results_view(&self, test: TypingTest, finish_instant: Instant) { + pub(super) fn show_results_view(&self, summary: TestSummary) { let continue_button = self.results_continue_button.get(); - let TypingTest { - config, - start_instant, - start_system_time, - } = test; - - let original_text = match config { - TestConfig::Generated { .. } => self.text_view.original_text(), - TestConfig::Finite => process_custom_text(&self.text_view.original_text()), - }; - let typed_text = self.text_view.typed_text(); - let results_view = self.results_view.get(); - let keystrokes = self.text_view.keystrokes(); - - let summary = TestSummary::new( - start_system_time, - start_instant, - finish_instant, - config, - &original_text, - &typed_text, - &keystrokes, - ); - results_view.set_summary(summary); let app = self.obj().kp_application(); @@ -657,7 +676,7 @@ impl imp::KpWindow { language, difficulty, duration, - } = config + } = summary.config { let is_personal_best = summary.accuracy > 0.9 && personal_best_vec