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