From ffa0628129dd65ce87b3f741ee84052945483745 Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Mon, 20 Jan 2025 12:01:16 +0100 Subject: [PATCH 01/12] chore: Rebase on main --- Cargo.lock | 88 ++- Cargo.toml | 2 + .../actions/arrow-into-box-symbolic.svg | 2 + .../icons/scalable/actions/graph-symbolic.svg | 2 + .../scalable/actions/lightbulb-symbolic.svg | 2 + data/resources/style-hc.css | 7 + data/resources/style.css | 37 +- src/graph_layout.rs | 0 src/widgets/interactive_graph.rs | 354 +++++++++ src/widgets/line_chart.rs | 315 ++++++++ src/widgets/mod.rs | 8 +- src/widgets/statistics_dialog.blp | 698 ++++++++++++++++++ src/widgets/statistics_dialog.rs | 103 +++ src/widgets/window.blp | 31 +- src/widgets/window.rs | 8 + src/widgets/window/session.rs | 8 +- src/widgets/window/ui_state.rs | 8 +- 17 files changed, 1656 insertions(+), 17 deletions(-) create mode 100644 data/resources/icons/scalable/actions/arrow-into-box-symbolic.svg create mode 100644 data/resources/icons/scalable/actions/graph-symbolic.svg create mode 100644 data/resources/icons/scalable/actions/lightbulb-symbolic.svg create mode 100644 data/resources/style-hc.css create mode 100644 src/graph_layout.rs create mode 100644 src/widgets/interactive_graph.rs create mode 100644 src/widgets/line_chart.rs create mode 100644 src/widgets/statistics_dialog.blp create mode 100644 src/widgets/statistics_dialog.rs diff --git a/Cargo.lock b/Cargo.lock index 9bb2d18..ea83c56 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" @@ -185,6 +197,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[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" version = "0.3.6" @@ -574,12 +598,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" @@ -622,7 +664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -637,6 +679,7 @@ version = "0.1.0" dependencies = [ "discord-presence", "gettext-rs", + "graphene-rs", "gtk4", "gvdb-macros", "i18n-format", @@ -644,6 +687,7 @@ dependencies = [ "libadwaita", "rand", "rayon", + "rusqlite", "strum", "strum_macros", "unicode-segmentation", @@ -693,6 +737,16 @@ version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "litrs" version = "0.4.1" @@ -834,6 +888,12 @@ dependencies = [ "objc", ] +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + [[package]] name = "pango" version = "0.20.7" @@ -1087,6 +1147,20 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1333,12 +1407,24 @@ dependencies = [ "getrandom", ] +[[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" diff --git a/Cargo.toml b/Cargo.toml index 4ae25ec..114cae6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,11 +25,13 @@ edition = "2021" 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" +rusqlite = "0.32.1" strum = "0.26.2" strum_macros = "0.26.2" unicode-segmentation = "1.11.0" 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 e53b111..907cc90 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.session .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/graph_layout.rs b/src/graph_layout.rs new file mode 100644 index 0000000..e69de29 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..8de9206 --- /dev/null +++ b/src/widgets/line_chart.rs @@ -0,0 +1,315 @@ +/* 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::widgets::KpInteractiveGraph; +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib; +use std::cell::{Cell, OnceCell, RefCell}; + +struct ChartItem { + pub title: String, + pub time_index: usize, + pub wpm: f64, + pub accuracy: f64, +} + +// 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 { + data: RefCell>, + 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 constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + + let main_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + main_box.set_parent(&*obj); + self.main_box + .set(main_box) + .expect("main box hasn't been initialized"); + + 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); + + self.main_box().append(&header_box); + + *self.data.borrow_mut() = vec![ + ChartItem { + title: String::from("January"), + time_index: 0, + wpm: 98., + accuracy: 0.97, + }, + ChartItem { + title: String::from("January"), + time_index: 1, + wpm: 100., + accuracy: 0.98, + }, + ChartItem { + title: String::from("January"), + time_index: 2, + wpm: 102., + accuracy: 0.95, + }, + ChartItem { + title: String::from("January"), + time_index: 3, + wpm: 100., + accuracy: 0.96, + }, + ChartItem { + title: String::from("January"), + time_index: 5, + wpm: 105., + accuracy: 0.99, + }, + ChartItem { + title: String::from("January"), + time_index: 6, + wpm: 103., + accuracy: 0.93, + }, + ChartItem { + title: String::from("January"), + time_index: 8, + wpm: 98., + accuracy: 0.94, + }, + ChartItem { + title: String::from("January"), + time_index: 9, + wpm: 99., + accuracy: 0.98, + }, + ]; + + let highest_y_val = self + .data + .borrow() + .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 self.data.borrow().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); + + self.main_box().append(&overlay); + } + + 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; +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index a7806c8..ee04f88 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,15 +18,21 @@ */ mod custom_text_dialog; +mod interactive_graph; mod language_row; +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 language_row::KpLanguageRow; +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..0b72d89 --- /dev/null +++ b/src/widgets/statistics_dialog.blp @@ -0,0 +1,698 @@ +/* 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: "month"; + title: _("Month"); + + child: $KpLineChart month_chart {}; + } + + Adw.ViewStackPage { + name: "year"; + title: _("Year"); + + child: $KpLineChart year_chart {}; + } + } + } + + // 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 { + label: "83"; + 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 { + label: "94%"; + 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 { + label: "77%"; + 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 { + label: "10m"; + xalign: 0; + + styles [ + "key-number" + ] + } + + Label { + label: "Practice Time"; + xalign: 0; + + styles [ + "dimmed" + ] + } + } + } + } + + // Missed Characters + Box { + margin-top: 18; + halign: fill; + height-request: 46; + + styles [ "group-header" ] + + Label { + label: _("Missed Characters"); + xalign: 0; + hexpand: true; + + styles [ + "heading" + ] + } + + MenuButton { + icon-name: "lightbulb-symbolic"; + direction: up; + valign: center; + + styles [ "flat" ] + + popover: Popover { + Label { + label: _("The characters that were mistyped the most during the last month, accompanied by their respective error rates."); + wrap: true; + max-width-chars: 45; + justify: center; + } + }; + } + } + + Adw.WrapBox mistyped_box { + child-spacing: 12; + line-spacing: 12; + justify: fill; + justify-last-line: true; + pack-direction: end_to_start; + wrap-reverse: true; + + Box { + orientation: vertical; + + Adw.Bin { + width-request: 75; + + styles [ + "card" + ] + + Label { + label: "q"; + valign: center; + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + yalign: 0; + + styles [ + "key-number", + ] + } + } + + Label { + label: "10%"; + margin-top: 6; + + styles [ + "error" + ] + } + } + + Box { + orientation: vertical; + + Adw.Bin { + width-request: 75; + + styles [ + "card" + ] + + Label { + label: "x"; + valign: center; + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + yalign: 0; + + styles [ + "key-number", + ] + } + } + + Label { + label: "10%"; + margin-top: 6; + + styles [ + "error" + ] + } + } + + Box { + orientation: vertical; + + Adw.Bin { + width-request: 75; + + styles [ + "card" + ] + + Label { + label: "å"; + valign: center; + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + yalign: 0; + + styles [ + "key-number", + ] + } + } + + Label { + label: "10%"; + margin-top: 6; + + styles [ + "error" + ] + } + } + + Box { + orientation: vertical; + + Adw.Bin { + width-request: 80; + + styles [ + "card" + ] + + Label { + label: "z"; + valign: center; + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + yalign: 0; + + styles [ + "key-number", + ] + } + } + + Label { + label: "10%"; + margin-top: 6; + + styles [ + "error" + ] + } + } + + Box { + orientation: vertical; + + Adw.Bin { + width-request: 80; + + styles [ + "card" + ] + + Label { + label: "a"; + valign: center; + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + yalign: 0; + + styles [ + "key-number", + ] + } + } + + Label { + label: "10%"; + margin-top: 6; + + styles [ + "error" + ] + } + } + + } + + // 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" + ] + } + } + } + + } + + // Missed Characters + 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..f650a7c --- /dev/null +++ b/src/widgets/statistics_dialog.rs @@ -0,0 +1,103 @@ +/* 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::widgets::KpLineChart; +use adw::prelude::*; +use adw::subclass::prelude::*; +use glib::subclass::Signal; +use gtk::glib; +use std::sync::OnceLock; + +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] + month_chart: TemplateChild, + #[template_child] + year_chart: 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(); + + self.stack.set_visible_child_name("statistics"); + + 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(); + } + } + impl WidgetImpl for KpStatisticsDialog {} + impl AdwDialogImpl for KpStatisticsDialog {} + impl KpStatisticsDialog {} +} + +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 ccd3710..6a5426e 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-session"; + }; + } } [session_status] diff --git a/src/widgets/window.rs b/src/widgets/window.rs index 7dd96e2..e56b211 100644 --- a/src/widgets/window.rs +++ b/src/widgets/window.rs @@ -53,6 +53,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, @@ -116,6 +120,10 @@ mod imp { klass.install_action("win.cancel-session", None, move |window, _, _| { window.imp().ready(); }); + + klass.install_action("win.statistics-dialog", None, move |window, _, _| { + window.imp().show_statistics_dialog(); + }); } fn instance_init(obj: &glib::subclass::InitializingObject) { diff --git a/src/widgets/window/session.rs b/src/widgets/window/session.rs index 9733d48..f781778 100644 --- a/src/widgets/window/session.rs +++ b/src/widgets/window/session.rs @@ -21,7 +21,7 @@ use super::*; use crate::application::KpApplication; use crate::text_generation; use crate::text_utils::{calculate_accuracy, calculate_wpm, process_custom_text, GraphemeState}; -use crate::widgets::{KpCustomTextDialog, KpTextLanguageDialog}; +use crate::widgets::{KpCustomTextDialog, KpStatisticsDialog, KpTextLanguageDialog}; use gettextrs::gettext; use glib::ControlFlow; use i18n_format::i18n_fmt; @@ -228,6 +228,12 @@ impl imp::KpWindow { ); } + 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.running.get() || self.obj().visible_dialog().is_some() { return; diff --git a/src/widgets/window/ui_state.rs b/src/widgets/window/ui_state.rs index f598d61..786986c 100644 --- a/src/widgets/window/ui_state.rs +++ b/src/widgets/window/ui_state.rs @@ -94,9 +94,10 @@ impl imp::KpWindow { self.text_view.set_running(false); self.text_view.set_accepts_input(true); self.main_stack.set_visible_child_name("session"); + self.header_bar_start + .set_visible_child_name("statistics_button"); self.status_stack.set_visible_child_name("ready"); self.menu_button.set_visible(true); - self.stop_button.set_visible(false); self.text_view.reset(); self.focus_text_view(); @@ -136,9 +137,6 @@ impl imp::KpWindow { // Ugly hack to stop the stop button from "flashing" when starting a session: // 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!( @@ -147,7 +145,7 @@ impl imp::KpWindow { move || { if imp.running.get() { imp.menu_button.set_visible(false); - imp.stop_button.set_opacity(1.); + imp.header_bar_start.set_visible_child_name("stop_button"); } } ), From 1f6a9d71b2b1cc8e815358cd9648d94c9a9f6622 Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Sun, 9 Feb 2025 13:32:25 +0100 Subject: [PATCH 02/12] temp --- Cargo.lock | 536 ++++++++++++++++++++++++++++++--- Cargo.toml | 4 +- src/database.rs | 69 +++++ src/main.rs | 3 + src/migrations/V1__initial.sql | 20 ++ src/typing_stats_db.rs | 24 ++ 6 files changed, 611 insertions(+), 45 deletions(-) create mode 100644 src/database.rs create mode 100644 src/migrations/V1__initial.sql create mode 100644 src/typing_stats_db.rs diff --git a/Cargo.lock b/Cargo.lock index ea83c56..8b21b3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "anyhow" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" + +[[package]] +name = "async-trait" +version = "0.1.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -37,9 +54,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" [[package]] name = "block" @@ -84,9 +101,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.7" +version = "1.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7" +checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" dependencies = [ "shlex", ] @@ -156,6 +173,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "discord-presence" version = "1.5.0" @@ -175,10 +201,21 @@ dependencies = [ "quork", "serde", "serde_json", - "thiserror", + "thiserror 2.0.11", "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" @@ -229,6 +266,15 @@ dependencies = [ "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" @@ -437,7 +483,7 @@ version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "715601f8f02e71baef9c1f94a657a9a77c192aea6097cf9ae7e5e177cd8cde68" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro-crate", "proc-macro2", "quote", @@ -622,6 +668,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -638,6 +690,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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[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" @@ -659,9 +850,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", "hashbrown 0.15.2", @@ -677,6 +868,7 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" name = "keypunch" version = "0.1.0" dependencies = [ + "anyhow", "discord-presence", "gettext-rs", "graphene-rs", @@ -687,6 +879,7 @@ dependencies = [ "libadwaita", "rand", "rayon", + "refinery", "rusqlite", "strum", "strum_macros", @@ -739,14 +932,21 @@ checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" dependencies = [ + "cc", "pkg-config", "vcpkg", ] +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + [[package]] name = "litrs" version = "0.4.1" @@ -781,9 +981,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "malloc_buf" @@ -811,9 +1011,9 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" +checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" dependencies = [ "adler2", ] @@ -839,6 +1039,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" @@ -947,6 +1153,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" @@ -965,6 +1177,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[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" @@ -1007,9 +1225,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" dependencies = [ "unicode-ident", ] @@ -1033,7 +1251,7 @@ dependencies = [ "cfg-if", "nix", "quork-proc", - "thiserror", + "thiserror 2.0.11", "windows", ] @@ -1118,6 +1336,50 @@ dependencies = [ "bitflags", ] +[[package]] +name = "refinery" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0904191f0566c3d3e0091d5cc8dec22e663d77def2d247b16e7a438b188bf75d" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bf253999e1899ae476c910b994959e341d84c4389ba9533d3dacbe06df04825" +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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd81f69687fe8a1fa10995108b3ffc7cdbd63e682a4f8fbfd1020130780d7e17" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn", +] + [[package]] name = "regex" version = "1.11.1" @@ -1149,9 +1411,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ "bitflags", "fallible-iterator", @@ -1199,9 +1461,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" [[package]] name = "serde" @@ -1225,9 +1487,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.134" +version = "1.0.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" +checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" dependencies = [ "itoa", "memchr", @@ -1250,6 +1512,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" @@ -1265,6 +1533,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1283,7 +1557,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -1292,15 +1566,26 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.95" +version = "2.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" 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" @@ -1308,7 +1593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66d23aaf9f331227789a99e8de4c91bf46703add012bdfd45fdecdfb2975a005" dependencies = [ "cfg-expr", - "heck", + "heck 0.5.0", "pkg-config", "toml", "version-compare", @@ -1328,24 +1613,85 @@ checksum = "bc1ee6eef34f12f765cb94725905c6312b6610ab2b0940889cfe58dae7bc3c72" [[package]] name = "thiserror" -version = "2.0.9" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl 2.0.11", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "thiserror-impl" -version = "2.0.9" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +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" @@ -1382,9 +1728,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" [[package]] name = "unicode-segmentation" @@ -1398,11 +1744,34 @@ 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.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" dependencies = [ "getrandom", ] @@ -1611,13 +1980,49 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.22" +version = "0.6.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39281189af81c07ec09db316b302a3e67bf9bd7cbf6c820b50e35fee9c2fa980" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" dependencies = [ "memchr", ] +[[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" @@ -1659,11 +2064,54 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +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", + "syn", +] + [[package]] name = "zvariant" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1200ee6ac32f1e5a312e455a949a4794855515d34f9909f4a3e082d14e1a56f" +checksum = "55e6b9b5f1361de2d5e7d9fd1ee5f6f7fcb6060618a1f82f3472f58f2b8d4be9" dependencies = [ "endi", "serde", @@ -1675,9 +2123,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687e3b97fae6c9104fbbd36c73d27d149abf04fb874e2efbd84838763daa8916" +checksum = "573a8dd76961957108b10f7a45bac6ab1ea3e9b7fe01aff88325dc57bb8f5c8b" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1688,9 +2136,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.0.2" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20d1d011a38f12360e5fcccceeff5e2c42a8eb7f27f0dcba97a0862ede05c9c6" +checksum = "ddd46446ea2a1f353bfda53e35f17633afa79f4fe290a611c94645c69fe96a50" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 114cae6..520690b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ 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"] } @@ -31,7 +32,8 @@ i18n-format = "0.2.0" include_dir = "0.7.3" rand = "0.8.5" rayon = "1.10.0" -rusqlite = "0.32.1" +refinery = { version = "0.8.14", features = ["rusqlite"] } +rusqlite = { version = "=0.31.0", features = ["bundled"] } # Locked until Refinery is updated strum = "0.26.2" strum_macros = "0.26.2" unicode-segmentation = "1.11.0" diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..83db38c --- /dev/null +++ b/src/database.rs @@ -0,0 +1,69 @@ +use crate::session_enums::SessionType; +use crate::text_generation::Language; +use anyhow::Result; +use gtk::glib; +use rusqlite::Connection; +use std::path::PathBuf; +use std::sync::LazyLock; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const DB_FILENAME: &'static str = "statistics.sqlite"; + +pub const DATABASE: LazyLock = LazyLock::new(|| { + let mut path = glib::user_data_dir(); + path.push(DB_FILENAME); + + TypingStatsDb::setup(path) +}); + +refinery::embed_migrations!("./src/migrations"); + +pub struct TestData { + pub timestamp: SystemTime, + pub finished: bool, + pub test_type: SessionType, + pub language: Option, + pub duration: Duration, + pub wpm: f64, + pub accuracy: f64, +} + +pub struct TypingStatsDb(Connection); + +impl TypingStatsDb { + pub fn setup(location: PathBuf) -> TypingStatsDb { + let mut conn = Connection::open(location).unwrap(); + migrations::runner().run(&mut conn).unwrap(); + + TypingStatsDb(conn) + } + + pub fn push_test(&self, data: &TestData) -> Result<()> { + self.0.execute( + " + INSERT INTO tests ( + timestamp, + finished, + test_type, + language, + duration, + wpm, + accuracy + ) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + data.timestamp + .duration_since(UNIX_EPOCH) + .expect("System time is always post-UNIX epoch") + .as_secs(), + data.finished, + data.test_type.to_string(), + data.language.map(|l| l.to_string()), + data.duration.as_secs(), + data.wpm, + data.accuracy, + ), + )?; + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 215a0a1..100a58b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod application; mod config; +mod database; mod discord_rpc; mod session_enums; mod text_generation; @@ -52,6 +53,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..8283c01 --- /dev/null +++ b/src/migrations/V1__initial.sql @@ -0,0 +1,20 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE tests ( + timestamp INTEGER NOT NULL, + finished INTEGER NOT NULL, + test_type TEXT NOT NULL, + language TEXT, + duration INTEGER, + wpm INTEGER NOT NULL, + accuracy INTEGER NOT NULL +); +CREATE INDEX test_time_fin_lang ON tests(timestamp, finished, language); + +CREATE TABLE keypresses ( + test_id INTEGER, + character TEXT NOT NULL, + total INTEGER NOT NULL, + missed INTEGER NOT NULL, + FOREIGN KEY(test_id) REFERENCES tests(rowid) +); \ No newline at end of file diff --git a/src/typing_stats_db.rs b/src/typing_stats_db.rs new file mode 100644 index 0000000..f404411 --- /dev/null +++ b/src/typing_stats_db.rs @@ -0,0 +1,24 @@ +use gtk::glib; +use rusqlite::Connection; +use std::path::PathBuf; + +const DB_FILENAME: &'static str = "typing_stats.sqlite"; + +pub struct TypingStatsDb(Connection); + +impl Default for TypingStatsDb { + fn default() -> Self { + let mut path = glib::user_data_dir(); + path.push(DB_FILENAME); + + TypingStatsDb::setup(path).unwrap() + } +} + +impl TypingStatsDb { + pub fn setup(location: PathBuf) -> rusqlite::Result { + let connection = Connection::open(location)?; + + Ok(TypingStatsDb(connection)) + } +} From fe9934811b8e1fa541afb727d51ef08399f97d49 Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Mon, 10 Feb 2025 16:40:21 +0100 Subject: [PATCH 03/12] add data properly to database --- src/database.rs | 92 +++++++++++++++++-------------- src/migrations/V1__initial.sql | 15 ++--- src/typing_test_utils.rs | 6 +- src/widgets/line_chart.rs | 2 +- src/widgets/window.rs | 2 +- src/widgets/window/typing_test.rs | 80 ++++++++++++++++----------- 6 files changed, 113 insertions(+), 84 deletions(-) diff --git a/src/database.rs b/src/database.rs index 955a694..86f106f 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,19 +1,15 @@ -use crate::typing_test_utils::TestSummary; -use crate::text_generation::Language; +use crate::typing_test_utils::*; use anyhow::Result; -use gtk::glib; use rusqlite::Connection; use std::path::PathBuf; use std::sync::LazyLock; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -const DB_FILENAME: &'static str = "statistics.sqlite"; +use std::time::UNIX_EPOCH; +use std::fs; pub const DATABASE: LazyLock = LazyLock::new(|| { - let mut path = glib::user_data_dir(); - path.push(DB_FILENAME); + let path = gtk::glib::user_data_dir().join("keypunch"); - TypingStatsDb::setup(path) + TypingStatsDb::setup(path).unwrap() }); refinery::embed_migrations!("./src/migrations"); @@ -21,40 +17,54 @@ refinery::embed_migrations!("./src/migrations"); pub struct TypingStatsDb(Connection); impl TypingStatsDb { - pub fn setup(location: PathBuf) -> TypingStatsDb { - let mut conn = Connection::open(location).unwrap(); + 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(); - TypingStatsDb(conn) + Ok(TypingStatsDb(conn)) } - // TODO - // pub fn push_test(&self, data: &TestSummary) -> Result<()> { - // self.0.execute( - // " - // INSERT INTO tests ( - // timestamp, - // finished, - // test_type, - // language, - // duration, - // wpm, - // accuracy - // ) - // VALUES (?, ?, ?, ?, ?, ?, ?)", - // ( - // data.timestamp - // .duration_since(UNIX_EPOCH) - // .expect("System time is always post-UNIX epoch") - // .as_secs(), - // data.finished, - // data.test_type.to_string(), - // data.language.map(|l| l.to_string()), - // data.duration.as_secs(), - // data.wpm, - // data.accuracy, - // ), - // )?; - // Ok(()) - // } + 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 + .duration_since(UNIX_EPOCH) + .expect("System time is always post-UNIX epoch") + .as_secs(), + summary.finished, + test_type, + language, + duration, + summary.real_duration.as_secs(), + summary.wpm, + summary.accuracy, + ), + )?; + Ok(()) + } } diff --git a/src/migrations/V1__initial.sql b/src/migrations/V1__initial.sql index 8283c01..715354b 100644 --- a/src/migrations/V1__initial.sql +++ b/src/migrations/V1__initial.sql @@ -1,13 +1,14 @@ PRAGMA foreign_keys = ON; CREATE TABLE tests ( - timestamp INTEGER NOT NULL, - finished INTEGER NOT NULL, - test_type TEXT NOT NULL, - language TEXT, - duration INTEGER, - wpm INTEGER NOT NULL, - accuracy INTEGER NOT NULL + timestamp INTEGER 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); diff --git a/src/typing_test_utils.rs b/src/typing_test_utils.rs index 6d0a11d..968a4ca 100644 --- a/src/typing_test_utils.rs +++ b/src/typing_test_utils.rs @@ -1,6 +1,6 @@ /* session_enums.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 @@ -139,6 +139,7 @@ pub struct TestSummary { pub wpm: f64, pub start_timestamp: SystemTime, pub accuracy: f64, + pub finished: bool, } impl TestSummary { @@ -150,6 +151,7 @@ impl TestSummary { 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(); @@ -161,6 +163,8 @@ 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/line_chart.rs b/src/widgets/line_chart.rs index 8de9206..dc5301c 100644 --- a/src/widgets/line_chart.rs +++ b/src/widgets/line_chart.rs @@ -21,7 +21,7 @@ use crate::widgets::KpInteractiveGraph; use adw::prelude::*; use adw::subclass::prelude::*; use gtk::glib; -use std::cell::{Cell, OnceCell, RefCell}; +use std::cell::{OnceCell, RefCell}; struct ChartItem { pub title: String, diff --git a/src/widgets/window.rs b/src/widgets/window.rs index b68f583..47d9117 100644 --- a/src/widgets/window.rs +++ b/src/widgets/window.rs @@ -110,7 +110,7 @@ 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, _, _| { diff --git a/src/widgets/window/typing_test.rs b/src/widgets/window/typing_test.rs index c1a26f9..7cbb0a2 100644 --- a/src/widgets/window/typing_test.rs +++ b/src/widgets/window/typing_test.rs @@ -22,6 +22,7 @@ use crate::typing_test_utils::TestSummary; use crate::text_generation; use crate::text_utils::{process_custom_text, GraphemeState}; use crate::widgets::{KpCustomTextDialog, KpStatisticsDialog, KpTextLanguageDialog}; +use crate::database::DATABASE; use gettextrs::gettext; use glib::ControlFlow; use i18n_format::i18n_fmt; @@ -335,8 +336,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; @@ -348,11 +349,9 @@ impl imp::KpWindow { } pub(super) fn frustration_relief(&self) { - if !self.is_running() { - return; - } + 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 @@ -369,8 +368,45 @@ 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); @@ -380,6 +416,8 @@ impl imp::KpWindow { self.obj().action_set_enabled("win.cancel-test", false); self.end_existing_inhibit(); + + summary } pub(super) fn hide_cursor(&self) { @@ -614,35 +652,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 +671,7 @@ impl imp::KpWindow { language, difficulty, duration, - } = config + } = summary.config { let is_personal_best = summary.accuracy > 0.9 && personal_best_vec From 972a9edd27747dc5a79800c75eebd4244a4606c3 Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Tue, 11 Feb 2025 16:08:41 +0100 Subject: [PATCH 04/12] rename typing test utils file correctly --- src/typing_test_utils.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/typing_test_utils.rs b/src/typing_test_utils.rs index 968a4ca..146dac4 100644 --- a/src/typing_test_utils.rs +++ b/src/typing_test_utils.rs @@ -1,4 +1,4 @@ -/* session_enums.rs +/* typing_test_utils.rs * * SPDX-FileCopyrightText: © 2024–2025 Brage Fuglseth * SPDX-License-Identifier: GPL-3.0-or-later @@ -168,3 +168,4 @@ impl TestSummary { } } + From 9c3a8046271118c4f36f8346e76c6da6af4a8eef Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Tue, 4 Mar 2025 13:29:28 +0100 Subject: [PATCH 05/12] more progress --- Cargo.lock | 13 + Cargo.toml | 3 +- src/database.rs | 59 ++++- src/migrations/V1__initial.sql | 2 +- src/typing_test_utils.rs | 11 +- src/widgets/line_chart.rs | 417 +++++++++++++----------------- src/widgets/statistics_dialog.blp | 4 +- src/widgets/statistics_dialog.rs | 13 +- 8 files changed, 267 insertions(+), 255 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b21b3e..f6b9bc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -883,6 +883,7 @@ dependencies = [ "rusqlite", "strum", "strum_macros", + "time", "unicode-segmentation", "unidecode", ] @@ -1065,6 +1066,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" @@ -1421,6 +1431,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "time", ] [[package]] @@ -1659,7 +1670,9 @@ checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", diff --git a/Cargo.toml b/Cargo.toml index 520690b..fbecaaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,9 +33,10 @@ 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"] } # Locked until Refinery is updated +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/src/database.rs b/src/database.rs index 86f106f..f9bf3b0 100644 --- a/src/database.rs +++ b/src/database.rs @@ -3,8 +3,15 @@ use anyhow::Result; use rusqlite::Connection; use std::path::PathBuf; use std::sync::LazyLock; -use std::time::UNIX_EPOCH; use std::fs; +use time::{OffsetDateTime, Duration, Time}; + +pub struct ChartItem { + pub title: String, + pub time_index: usize, + pub wpm: f64, + pub accuracy: f64, +} pub const DATABASE: LazyLock = LazyLock::new(|| { let path = gtk::glib::user_data_dir().join("keypunch"); @@ -52,10 +59,7 @@ impl TypingStatsDb { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( - summary.start_timestamp - .duration_since(UNIX_EPOCH) - .expect("System time is always post-UNIX epoch") - .as_secs(), + summary.start_timestamp, summary.finished, test_type, language, @@ -67,4 +71,49 @@ impl TypingStatsDb { )?; 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: "Test".to_string(), + 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) + } } diff --git a/src/migrations/V1__initial.sql b/src/migrations/V1__initial.sql index 715354b..7c4112c 100644 --- a/src/migrations/V1__initial.sql +++ b/src/migrations/V1__initial.sql @@ -1,7 +1,7 @@ PRAGMA foreign_keys = ON; CREATE TABLE tests ( - timestamp INTEGER NOT NULL, + timestamp TEXT NOT NULL, finished INTEGER NOT NULL, test_type TEXT NOT NULL, language TEXT, diff --git a/src/typing_test_utils.rs b/src/typing_test_utils.rs index 146dac4..4f7bd18 100644 --- a/src/typing_test_utils.rs +++ b/src/typing_test_utils.rs @@ -23,7 +23,8 @@ 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 time::OffsetDateTime; use strum_macros::{Display as EnumDisplay, EnumIter, EnumString}; #[derive(Clone, Copy, PartialEq, EnumString, EnumDisplay)] @@ -119,7 +120,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 { @@ -127,7 +128,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()), } } } @@ -137,14 +138,14 @@ 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, diff --git a/src/widgets/line_chart.rs b/src/widgets/line_chart.rs index dc5301c..a80c419 100644 --- a/src/widgets/line_chart.rs +++ b/src/widgets/line_chart.rs @@ -17,19 +17,13 @@ * 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}; -struct ChartItem { - pub title: String, - pub time_index: usize, - pub wpm: f64, - pub accuracy: f64, -} - // 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. @@ -42,7 +36,6 @@ mod imp { #[derive(Default)] pub struct KpLineChart { - data: RefCell>, main_box: OnceCell, } @@ -58,258 +51,204 @@ mod imp { } impl ObjectImpl for KpLineChart { - fn constructed(&self) { - self.parent_constructed(); + fn dispose(&self) { + while let Some(child) = self.obj().first_child() { + child.unparent(); + } + } + } - let obj = self.obj(); + impl WidgetImpl for KpLineChart {} - let main_box = gtk::Box::new(gtk::Orientation::Vertical, 6); - main_box.set_parent(&*obj); + impl KpLineChart { + fn main_box(&self) -> >k::Box { self.main_box - .set(main_box) - .expect("main box hasn't been initialized"); + .get() + .expect("main box initialized during construction") + } + } +} - 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") +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(["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) + .css_classes(["dimmed", "caption"]) .build(); - let wpm_header = gtk::Label::builder() - .label("Words per Minute") + + 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.) - .hexpand(true) - .css_classes(["caption", "accent"]) + .margin_end(12) + .css_classes(["accent", "caption"]) .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() + let ylabel_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .margin_start(12) - .margin_end(12) - .margin_top(12) - .margin_bottom(12) + .halign(gtk::Align::Fill) + .margin_bottom(2) .build(); + ylabel_box.append(&acc_label); + ylabel_box.append(&wpm_label); - header_box.append(&accuracy_box); - header_box.append(&wpm_box); - - self.main_box().append(&header_box); - - *self.data.borrow_mut() = vec![ - ChartItem { - title: String::from("January"), - time_index: 0, - wpm: 98., - accuracy: 0.97, - }, - ChartItem { - title: String::from("January"), - time_index: 1, - wpm: 100., - accuracy: 0.98, - }, - ChartItem { - title: String::from("January"), - time_index: 2, - wpm: 102., - accuracy: 0.95, - }, - ChartItem { - title: String::from("January"), - time_index: 3, - wpm: 100., - accuracy: 0.96, - }, - ChartItem { - title: String::from("January"), - time_index: 5, - wpm: 105., - accuracy: 0.99, - }, - ChartItem { - title: String::from("January"), - time_index: 6, - wpm: 103., - accuracy: 0.93, - }, - ChartItem { - title: String::from("January"), - time_index: 8, - wpm: 98., - accuracy: 0.94, - }, - ChartItem { - title: String::from("January"), - time_index: 9, - wpm: 99., - accuracy: 0.98, - }, - ]; - - let highest_y_val = self - .data - .borrow() - .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 separator = gtk::Separator::builder() + .orientation(gtk::Orientation::Vertical) + .build(); - let interactive_graph = KpInteractiveGraph::new(); - interactive_graph.set_margin_start(54); - interactive_graph.set_margin_end(54); - - for item in self.data.borrow().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 ruler = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .valign(gtk::Align::End) + .build(); - let overlay = gtk::Overlay::new(); - overlay.set_child(Some(&ruler_box)); - overlay.add_overlay(&interactive_graph); + ruler.append(&ylabel_box); + ruler.append(&separator); - self.main_box().append(&overlay); - } + let bin = adw::Bin::builder().child(&ruler).build(); - fn dispose(&self) { - while let Some(child) = self.obj().first_child() { - child.unparent(); + if i != (RULER_COUNT - 1) { + bin.set_height_request(ruler_height); } + + ruler_box.prepend(&bin); } - } - impl WidgetImpl for KpLineChart {} + 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(); - impl KpLineChart { - fn main_box(&self) -> >k::Box { - self.main_box - .get() - .expect("main box initialized during construction") + 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); } - } -} -glib::wrapper! { - pub struct KpLineChart(ObjectSubclass) - @extends gtk::Widget; + 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/statistics_dialog.blp b/src/widgets/statistics_dialog.blp index 0b72d89..0e36623 100644 --- a/src/widgets/statistics_dialog.blp +++ b/src/widgets/statistics_dialog.blp @@ -104,14 +104,14 @@ template $KpStatisticsDialog: Adw.Dialog { name: "month"; title: _("Month"); - child: $KpLineChart month_chart {}; + child: Adw.Bin month_bin {}; } Adw.ViewStackPage { name: "year"; title: _("Year"); - child: $KpLineChart year_chart {}; + child: Adw.Bin year_bin {}; } } } diff --git a/src/widgets/statistics_dialog.rs b/src/widgets/statistics_dialog.rs index f650a7c..527ba01 100644 --- a/src/widgets/statistics_dialog.rs +++ b/src/widgets/statistics_dialog.rs @@ -23,6 +23,9 @@ use adw::subclass::prelude::*; use glib::subclass::Signal; use gtk::glib; use std::sync::OnceLock; +use crate::database::DATABASE; +use crate::database::ChartItem; +use time::{Time, OffsetDateTime, Duration}; mod imp { use super::*; @@ -37,9 +40,9 @@ mod imp { #[template_child] scrolled_window: TemplateChild, #[template_child] - month_chart: TemplateChild, + month_bin: TemplateChild, #[template_child] - year_chart: TemplateChild, + year_bin: TemplateChild, } #[glib::object_subclass] @@ -84,6 +87,12 @@ mod imp { .transform_to(|_, scroll_position: f64| Some(scroll_position > 0.)) .sync_create() .build(); + + let month_data = DATABASE.get_past_month().unwrap(); // TODO: Handle the no data case + + let month_stats_chart = KpLineChart::new(&month_data); + + self.month_bin.set_child(Some(&month_stats_chart)); } } impl WidgetImpl for KpStatisticsDialog {} From 2928a5b5e788dbf85b95558c8ceec0afd9c8face Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Fri, 7 Mar 2025 15:41:42 +0100 Subject: [PATCH 06/12] finish line chart --- po/POTFILES.in | 13 +++++ src/database.rs | 84 ++++++++++++++++++++++++++++++- src/widgets/statistics_dialog.blp | 14 +++--- src/widgets/statistics_dialog.rs | 10 ++-- 4 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 po/POTFILES.in diff --git a/po/POTFILES.in b/po/POTFILES.in new file mode 100644 index 0000000..ca317ab --- /dev/null +++ b/po/POTFILES.in @@ -0,0 +1,13 @@ +data/dev.bragefuglseth.Keypunch.desktop.in.in +data/dev.bragefuglseth.Keypunch.metainfo.xml.in.in +data/dev.bragefuglseth.Keypunch.gschema.xml +data/resources/gtk/help-overlay.ui +src/widgets/custom_text_dialog.blp +src/widgets/results_view.blp +src/widgets/results_view.rs +src/widgets/text_language_dialog.blp +src/widgets/text_view.blp +src/widgets/window.blp +src/widgets/window.rs +src/widgets/window/typing_test.rs +src/widgets/window/ui_state.rs \ No newline at end of file diff --git a/src/database.rs b/src/database.rs index f9bf3b0..2b30f95 100644 --- a/src/database.rs +++ b/src/database.rs @@ -4,7 +4,9 @@ use rusqlite::Connection; use std::path::PathBuf; use std::sync::LazyLock; use std::fs; -use time::{OffsetDateTime, Duration, Time}; +use time::{Date, Month, OffsetDateTime, Duration, Time}; +use i18n_format::i18n_fmt; +use gettextrs::gettext; pub struct ChartItem { pub title: String, @@ -97,7 +99,7 @@ impl TypingStatsDb { if let Ok((wpm, accuracy)) = DATABASE.average_from_period(start, end) { Some(ChartItem { - title: "Test".to_string(), + title: formatted_date(start.date()), time_index: n as usize, wpm, accuracy, @@ -116,4 +118,82 @@ impl TypingStatsDb { 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.clone().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) + } +} + +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) }, + } } diff --git a/src/widgets/statistics_dialog.blp b/src/widgets/statistics_dialog.blp index 0e36623..c763e0f 100644 --- a/src/widgets/statistics_dialog.blp +++ b/src/widgets/statistics_dialog.blp @@ -101,17 +101,19 @@ template $KpStatisticsDialog: Adw.Dialog { margin-bottom: 12; Adw.ViewStackPage { - name: "month"; - title: _("Month"); + 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 month_bin {}; + child: Adw.Bin daily_bin {}; } Adw.ViewStackPage { - name: "year"; - title: _("Year"); + name: "monthly"; + // Translators: label for viewing average typing speed and accuracy for each *month* in the last *year* + title: _("Monthly"); - child: Adw.Bin year_bin {}; + child: Adw.Bin monthly_bin {}; } } } diff --git a/src/widgets/statistics_dialog.rs b/src/widgets/statistics_dialog.rs index 527ba01..26fa75f 100644 --- a/src/widgets/statistics_dialog.rs +++ b/src/widgets/statistics_dialog.rs @@ -40,9 +40,9 @@ mod imp { #[template_child] scrolled_window: TemplateChild, #[template_child] - month_bin: TemplateChild, + daily_bin: TemplateChild, #[template_child] - year_bin: TemplateChild, + monthly_bin: TemplateChild, } #[glib::object_subclass] @@ -89,10 +89,12 @@ mod imp { .build(); let month_data = DATABASE.get_past_month().unwrap(); // TODO: Handle the no data case - let month_stats_chart = KpLineChart::new(&month_data); + self.daily_bin.set_child(Some(&month_stats_chart)); - self.month_bin.set_child(Some(&month_stats_chart)); + let year_data = DATABASE.get_past_year().unwrap(); // TODO: Handle the no data case + let year_stats_chart = KpLineChart::new(&year_data); + self.monthly_bin.set_child(Some(&year_stats_chart)); } } impl WidgetImpl for KpStatisticsDialog {} From 22dc98afdcfff7ff84f91709b00654b814f2c31f Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Mon, 17 Mar 2025 17:38:50 +0100 Subject: [PATCH 07/12] temp --- src/database.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/database.rs b/src/database.rs index 2b30f95..fad7461 100644 --- a/src/database.rs +++ b/src/database.rs @@ -124,7 +124,7 @@ impl TypingStatsDb { let mut month_data: Vec = (0..12) .filter_map(|n| { - let mut start = now.clone().replace_day(1).unwrap().replace_time(Time::MIDNIGHT); + 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()); @@ -155,6 +155,24 @@ impl TypingStatsDb { Some(month_data) } + + pub fn last_month(&self) -> Option<(f64, f64, f64, String)> { + let now = OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc()); + + let start = now.replace_time(Time::MIDNIGHT) - Duration::days(29); + let (wpm, accuracy) = self.average_from_period(start, now).ok()?; + + let finished_share = self.0.query_row( + "SELECT (SUM(finished), COUNT(*) + 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)?).floor() as usize), + ).ok()?; + + todo!() + } } fn formatted_date(date: Date) -> String { From f3f0736f21fd6445e8d9f002d11c209b96cedd5d Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Wed, 26 Mar 2025 17:45:55 +0100 Subject: [PATCH 08/12] (mostly) finish monthly summary --- .../dev.bragefuglseth.Keypunch.Source.svg | 6 +++--- src/database.rs | 19 +++++++++++++------ src/widgets/statistics_dialog.blp | 12 ++++-------- src/widgets/statistics_dialog.rs | 17 ++++++++++++++++- 4 files changed, 36 insertions(+), 18 deletions(-) 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/src/database.rs b/src/database.rs index fad7461..46e1f38 100644 --- a/src/database.rs +++ b/src/database.rs @@ -15,6 +15,13 @@ pub struct ChartItem { 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"); @@ -156,22 +163,22 @@ impl TypingStatsDb { Some(month_data) } - pub fn last_month(&self) -> Option<(f64, f64, f64, String)> { + 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(29); + let start = now.replace_time(Time::MIDNIGHT) - Duration::days(27); let (wpm, accuracy) = self.average_from_period(start, now).ok()?; - let finished_share = self.0.query_row( - "SELECT (SUM(finished), COUNT(*) + let finish_rate = self.0.query_row( + "SELECT SUM(finished), COUNT(*) 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)?).floor() as usize), + |row| Ok(row.get::<_, f64>(0)? / row.get::<_, f64>(1)?), ).ok()?; - todo!() + Some(PeriodSummary { wpm, accuracy, finish_rate, practice_time: String::from("todo")}) } } diff --git a/src/widgets/statistics_dialog.blp b/src/widgets/statistics_dialog.blp index c763e0f..97029b2 100644 --- a/src/widgets/statistics_dialog.blp +++ b/src/widgets/statistics_dialog.blp @@ -171,8 +171,7 @@ template $KpStatisticsDialog: Adw.Dialog { margin-start: 18; margin-end: 18; - Label { - label: "83"; + Label month_wpm_label { xalign: 0; styles [ @@ -202,8 +201,7 @@ template $KpStatisticsDialog: Adw.Dialog { margin-start: 18; margin-end: 18; - Label { - label: "94%"; + Label month_accuracy_label { xalign: 0; styles [ @@ -233,8 +231,7 @@ template $KpStatisticsDialog: Adw.Dialog { margin-start: 18; margin-end: 18; - Label { - label: "77%"; + Label month_finish_rate_label { xalign: 0; styles [ @@ -265,7 +262,6 @@ template $KpStatisticsDialog: Adw.Dialog { margin-end: 18; Label { - label: "10m"; xalign: 0; styles [ @@ -273,7 +269,7 @@ template $KpStatisticsDialog: Adw.Dialog { ] } - Label { + Label month_practice_time_label { label: "Practice Time"; xalign: 0; diff --git a/src/widgets/statistics_dialog.rs b/src/widgets/statistics_dialog.rs index 26fa75f..496e8fe 100644 --- a/src/widgets/statistics_dialog.rs +++ b/src/widgets/statistics_dialog.rs @@ -24,8 +24,9 @@ use glib::subclass::Signal; use gtk::glib; use std::sync::OnceLock; use crate::database::DATABASE; -use crate::database::ChartItem; +use crate::database::{PeriodSummary, ChartItem}; use time::{Time, OffsetDateTime, Duration}; +use i18n_format::i18n_fmt; mod imp { use super::*; @@ -43,6 +44,14 @@ mod imp { 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] @@ -95,6 +104,12 @@ mod imp { let year_data = DATABASE.get_past_year().unwrap(); // TODO: Handle the no data case let year_stats_chart = KpLineChart::new(&year_data); self.monthly_bin.set_child(Some(&year_stats_chart)); + + let month_summary = DATABASE.last_month_summary().unwrap(); // TODO: Handle the no data case + + 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()) }); } } impl WidgetImpl for KpStatisticsDialog {} From 455c8938163c3d21b75618edc1c6b3906bade249 Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Sun, 6 Apr 2025 15:56:32 +0200 Subject: [PATCH 09/12] cargo fmt --- src/database.rs | 68 ++++++++++++++++++++++--------- src/main.rs | 2 +- src/text_generation.rs | 5 +-- src/typing_test_utils.rs | 6 +-- src/widgets/statistics_dialog.rs | 18 ++++---- src/widgets/window.rs | 2 +- src/widgets/window/typing_test.rs | 37 +++++++++-------- 7 files changed, 84 insertions(+), 54 deletions(-) diff --git a/src/database.rs b/src/database.rs index 46e1f38..b38e0a0 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,12 +1,12 @@ 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 std::fs; -use time::{Date, Month, OffsetDateTime, Duration, Time}; -use i18n_format::i18n_fmt; -use gettextrs::gettext; +use time::{Date, Duration, Month, OffsetDateTime, Time}; pub struct ChartItem { pub title: String, @@ -45,12 +45,21 @@ impl TypingStatsDb { 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, .. } => { + TestConfig::Generated { + difficulty, + language, + duration, + .. + } => { let difficulty = match difficulty { GeneratedTestDifficulty::Simple => "Simple", GeneratedTestDifficulty::Advanced => "Advanced", }; - (difficulty, Some(language.to_string()), Some(duration.to_string())) + ( + difficulty, + Some(language.to_string()), + Some(duration.to_string()), + ) } }; @@ -82,7 +91,11 @@ impl TypingStatsDb { } // Returns the average WPM and accuracy at a given date - pub fn average_from_period(&self, start: OffsetDateTime, end: OffsetDateTime) -> rusqlite::Result<(f64, f64)> { + 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 @@ -90,7 +103,7 @@ impl TypingStatsDb { 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)?)) + |row| Ok((row.get(0)?, row.get(1)?)), ) } @@ -117,7 +130,10 @@ impl TypingStatsDb { }) .collect(); - let &ChartItem { time_index: time_offset, .. } = month_data.get(0)?; + let &ChartItem { + time_index: time_offset, + .. + } = month_data.get(0)?; for item in month_data.iter_mut() { item.time_index -= time_offset; @@ -133,13 +149,16 @@ impl TypingStatsDb { .filter_map(|n| { let mut start = now.replace_day(1).unwrap().replace_time(Time::MIDNIGHT); - for _ in 0..(11-n) { + 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); + 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 { @@ -154,7 +173,10 @@ impl TypingStatsDb { }) .collect(); - let &ChartItem { time_index: time_offset, .. } = month_data.get(0)?; + let &ChartItem { + time_index: time_offset, + .. + } = month_data.get(0)?; for item in month_data.iter_mut() { item.time_index -= time_offset; @@ -169,16 +191,24 @@ impl TypingStatsDb { let start = now.replace_time(Time::MIDNIGHT) - Duration::days(27); let (wpm, accuracy) = self.average_from_period(start, now).ok()?; - let finish_rate = self.0.query_row( - "SELECT SUM(finished), COUNT(*) + let finish_rate = self + .0 + .query_row( + "SELECT SUM(finished), COUNT(*) 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)?), - ).ok()?; - - Some(PeriodSummary { wpm, accuracy, finish_rate, practice_time: String::from("todo")}) + (start.unix_timestamp(), now.unix_timestamp()), + |row| Ok(row.get::<_, f64>(0)? / row.get::<_, f64>(1)?), + ) + .ok()?; + + Some(PeriodSummary { + wpm, + accuracy, + finish_rate, + practice_time: String::from("todo"), + }) } } diff --git a/src/main.rs b/src/main.rs index 587b4ad..1234ba4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,10 +21,10 @@ mod application; mod config; mod database; mod discord_rpc; -mod typing_test_utils; mod settings; mod text_generation; mod text_utils; +mod typing_test_utils; mod widgets; use self::application::KpApplication; diff --git a/src/text_generation.rs b/src/text_generation.rs index 346de49..62695ba 100644 --- a/src/text_generation.rs +++ b/src/text_generation.rs @@ -148,8 +148,7 @@ type Numerals = [&'static str; 10]; const WESTERN_ARABIC_NUMERALS: &'static Numerals = &["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; -const PERSIAN_NUMERALS: &'static Numerals = - &["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; +const PERSIAN_NUMERALS: &'static Numerals = &["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; const DEVANAGARI_NUMERALS: &'static Numerals = &["०", "१", "२", "३", "४", "५", "६", "७", "८", "९"]; const BANGLA_NUMERALS: &'static Numerals = &["০", "১", "২", "৩", "৪", "৫", "৬", "৭", "৮", "৯"]; @@ -304,7 +303,6 @@ pub fn advanced(language: Language) -> String { Punctuation::suffix("؟", true, 0.3), Punctuation::wrapping("\"", "\"", false, 0.0), Punctuation::wrapping("(", ")", false, 0.1), - ], PERSIAN_NUMERALS, ), @@ -453,4 +451,3 @@ fn random_number_weighted(numerals: &Numerals, rng: &mut ThreadRng) -> String { s } - diff --git a/src/typing_test_utils.rs b/src/typing_test_utils.rs index 4f7bd18..ed1855d 100644 --- a/src/typing_test_utils.rs +++ b/src/typing_test_utils.rs @@ -24,8 +24,8 @@ use gtk::gio; use gtk::prelude::*; use std::str::FromStr; use std::time::{Duration, Instant}; -use time::OffsetDateTime; use strum_macros::{Display as EnumDisplay, EnumIter, EnumString}; +use time::OffsetDateTime; #[derive(Clone, Copy, PartialEq, EnumString, EnumDisplay)] pub enum GeneratedTestDifficulty { @@ -164,9 +164,7 @@ impl TestSummary { wpm: calculate_wpm(real_duration, &original, &typed), start_timestamp, accuracy: correct_keystrokes as f64 / total_keystrokes as f64, - finished + finished, } } } - - diff --git a/src/widgets/statistics_dialog.rs b/src/widgets/statistics_dialog.rs index 496e8fe..9f3c8c4 100644 --- a/src/widgets/statistics_dialog.rs +++ b/src/widgets/statistics_dialog.rs @@ -17,16 +17,16 @@ * along with this program. If not, see . */ +use crate::database::DATABASE; +use crate::database::{ChartItem, PeriodSummary}; use crate::widgets::KpLineChart; use adw::prelude::*; use adw::subclass::prelude::*; use glib::subclass::Signal; use gtk::glib; -use std::sync::OnceLock; -use crate::database::DATABASE; -use crate::database::{PeriodSummary, ChartItem}; -use time::{Time, OffsetDateTime, Duration}; use i18n_format::i18n_fmt; +use std::sync::OnceLock; +use time::{Duration, OffsetDateTime, Time}; mod imp { use super::*; @@ -107,9 +107,13 @@ mod imp { let month_summary = DATABASE.last_month_summary().unwrap(); // TODO: Handle the no data case - 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_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()) }, + ); } } impl WidgetImpl for KpStatisticsDialog {} diff --git a/src/widgets/window.rs b/src/widgets/window.rs index 47d9117..ed2bf5c 100644 --- a/src/widgets/window.rs +++ b/src/widgets/window.rs @@ -23,8 +23,8 @@ mod typing_test; use crate::application::KpApplication; use crate::config::APP_ID; -use crate::typing_test_utils::*; use crate::settings; +use crate::typing_test_utils::*; use crate::widgets::{KpResultsView, KpTextView}; use adw::prelude::*; use adw::subclass::prelude::*; diff --git a/src/widgets/window/typing_test.rs b/src/widgets/window/typing_test.rs index 7cbb0a2..0a322a9 100644 --- a/src/widgets/window/typing_test.rs +++ b/src/widgets/window/typing_test.rs @@ -18,11 +18,11 @@ */ use super::*; -use crate::typing_test_utils::TestSummary; +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, KpStatisticsDialog, KpTextLanguageDialog}; -use crate::database::DATABASE; use gettextrs::gettext; use glib::ControlFlow; use i18n_format::i18n_fmt; @@ -276,10 +276,10 @@ impl imp::KpWindow { let settings = app.settings(); // Discord IPC - self.obj().kp_application().discord_rpc().set_activity( - TestConfig::from_settings(&settings), - PresenceState::Ready, - ); + self.obj() + .kp_application() + .discord_rpc() + .set_activity(TestConfig::from_settings(&settings), PresenceState::Ready); self.end_existing_inhibit(); } @@ -305,8 +305,7 @@ impl imp::KpWindow { move || { if imp.is_running() { imp.menu_button.set_visible(false); - imp.header_bar_start - .set_visible_child_name("stop_button"); + imp.header_bar_start.set_visible_child_name("stop_button"); } } ), @@ -349,7 +348,9 @@ impl imp::KpWindow { } pub(super) fn frustration_relief(&self) { - let Some(test) = self.current_test.get() else { return; }; + let Some(test) = self.current_test.get() else { + return; + }; self.end_test(test, false); self.main_stack.set_visible_child_name("frustration-relief"); @@ -370,7 +371,9 @@ impl imp::KpWindow { } #[template_callback] pub(super) fn cancel_test(&self) { - let Some(test) = self.current_test.get() else { return; }; + let Some(test) = self.current_test.get() else { + return; + }; self.end_test(test, false); self.ready(); } @@ -400,7 +403,7 @@ impl imp::KpWindow { &original_text, &typed_text, &keystrokes, - finished + finished, ); if let Err(e) = DATABASE.push_summary(&summary) { @@ -732,13 +735,11 @@ pub(super) fn add_personal_best( let (new_test_type, new_duration, new_language, new_wpm) = new; old.into_iter() - .filter( - |(stored_test_type, stored_duration, stored_lang_code, _)| { - *stored_test_type != new_test_type - || *stored_duration != new_duration - || *stored_lang_code != new_language - }, - ) + .filter(|(stored_test_type, stored_duration, stored_lang_code, _)| { + *stored_test_type != new_test_type + || *stored_duration != new_duration + || *stored_lang_code != new_language + }) .chain(once(( new_test_type.to_string(), new_duration.to_string(), From 036968352bdf3fe56c1564c8a9c150e6ccf64557 Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Sun, 6 Apr 2025 16:23:25 +0200 Subject: [PATCH 10/12] partially implement the no data handling --- src/widgets/statistics_dialog.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/widgets/statistics_dialog.rs b/src/widgets/statistics_dialog.rs index 9f3c8c4..dd01945 100644 --- a/src/widgets/statistics_dialog.rs +++ b/src/widgets/statistics_dialog.rs @@ -20,6 +20,7 @@ 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; @@ -87,8 +88,6 @@ mod imp { fn constructed(&self) { self.parent_constructed(); - self.stack.set_visible_child_name("statistics"); - let header_bar = self.header_bar.get(); self.scrolled_window .vadjustment() @@ -97,15 +96,26 @@ mod imp { .sync_create() .build(); - let month_data = DATABASE.get_past_month().unwrap(); // TODO: Handle the no data case + 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 year_data = DATABASE.get_past_year().unwrap(); // TODO: Handle the no data case + 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 month_summary = DATABASE.last_month_summary().unwrap(); // TODO: Handle the no data case + let Some(month_summary) = DATABASE.last_month_summary() else { return false; }; self.month_wpm_label .set_label(&month_summary.wpm.floor().to_string()); @@ -114,11 +124,10 @@ mod imp { self.month_finish_rate_label.set_label( &i18n_fmt! { i18n_fmt("{}%", (month_summary.finish_rate * 100.).floor()) }, ); + + true } } - impl WidgetImpl for KpStatisticsDialog {} - impl AdwDialogImpl for KpStatisticsDialog {} - impl KpStatisticsDialog {} } glib::wrapper! { From e7a67f68207fea3b4977ae5941e732d108326d51 Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Mon, 7 Apr 2025 17:26:02 +0200 Subject: [PATCH 11/12] practice time --- src/database.rs | 37 +++++++++++++++++++++++++------ src/widgets/statistics_dialog.blp | 4 ++-- src/widgets/statistics_dialog.rs | 3 +++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/database.rs b/src/database.rs index b38e0a0..dfecc85 100644 --- a/src/database.rs +++ b/src/database.rs @@ -191,15 +191,15 @@ impl TypingStatsDb { let start = now.replace_time(Time::MIDNIGHT) - Duration::days(27); let (wpm, accuracy) = self.average_from_period(start, now).ok()?; - let finish_rate = self + let (finish_rate, practice_time) = self .0 .query_row( - "SELECT SUM(finished), COUNT(*) - FROM tests - WHERE UNIXEPOCH(timestamp) BETWEEN ? AND ? - AND test_type IN ('Simple', 'Advanced')", + "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| Ok((row.get::<_, f64>(0)? / row.get::<_, f64>(1)?, row.get::<_, i64>(2)?)), ) .ok()?; @@ -207,11 +207,13 @@ impl TypingStatsDb { wpm, accuracy, finish_rate, - practice_time: String::from("todo"), + 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(); @@ -252,3 +254,24 @@ fn formatted_month(date: Date) -> String { 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/statistics_dialog.blp b/src/widgets/statistics_dialog.blp index 97029b2..28df483 100644 --- a/src/widgets/statistics_dialog.blp +++ b/src/widgets/statistics_dialog.blp @@ -261,7 +261,7 @@ template $KpStatisticsDialog: Adw.Dialog { margin-start: 18; margin-end: 18; - Label { + Label month_practice_time_label { xalign: 0; styles [ @@ -269,7 +269,7 @@ template $KpStatisticsDialog: Adw.Dialog { ] } - Label month_practice_time_label { + Label { label: "Practice Time"; xalign: 0; diff --git a/src/widgets/statistics_dialog.rs b/src/widgets/statistics_dialog.rs index dd01945..ec60754 100644 --- a/src/widgets/statistics_dialog.rs +++ b/src/widgets/statistics_dialog.rs @@ -124,6 +124,9 @@ mod imp { 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 } From 28ab77b08d6ae4bb677ca8a5f755903a0e907779 Mon Sep 17 00:00:00 2001 From: Brage Fuglseth Date: Mon, 9 Jun 2025 18:15:25 +0200 Subject: [PATCH 12/12] drop keypresses for now --- src/migrations/V1__initial.sql | 10 +- src/widgets/statistics_dialog.blp | 223 +----------------------------- 2 files changed, 2 insertions(+), 231 deletions(-) diff --git a/src/migrations/V1__initial.sql b/src/migrations/V1__initial.sql index 7c4112c..e384bad 100644 --- a/src/migrations/V1__initial.sql +++ b/src/migrations/V1__initial.sql @@ -10,12 +10,4 @@ CREATE TABLE tests ( wpm INTEGER NOT NULL, accuracy INTEGER NOT NULL ); -CREATE INDEX test_time_fin_lang ON tests(timestamp, finished, language); - -CREATE TABLE keypresses ( - test_id INTEGER, - character TEXT NOT NULL, - total INTEGER NOT NULL, - missed INTEGER NOT NULL, - FOREIGN KEY(test_id) REFERENCES tests(rowid) -); \ No newline at end of file +CREATE INDEX test_time_fin_lang ON tests(timestamp, finished, language); \ No newline at end of file diff --git a/src/widgets/statistics_dialog.blp b/src/widgets/statistics_dialog.blp index 28df483..4bf1794 100644 --- a/src/widgets/statistics_dialog.blp +++ b/src/widgets/statistics_dialog.blp @@ -281,227 +281,6 @@ template $KpStatisticsDialog: Adw.Dialog { } } - // Missed Characters - Box { - margin-top: 18; - halign: fill; - height-request: 46; - - styles [ "group-header" ] - - Label { - label: _("Missed Characters"); - xalign: 0; - hexpand: true; - - styles [ - "heading" - ] - } - - MenuButton { - icon-name: "lightbulb-symbolic"; - direction: up; - valign: center; - - styles [ "flat" ] - - popover: Popover { - Label { - label: _("The characters that were mistyped the most during the last month, accompanied by their respective error rates."); - wrap: true; - max-width-chars: 45; - justify: center; - } - }; - } - } - - Adw.WrapBox mistyped_box { - child-spacing: 12; - line-spacing: 12; - justify: fill; - justify-last-line: true; - pack-direction: end_to_start; - wrap-reverse: true; - - Box { - orientation: vertical; - - Adw.Bin { - width-request: 75; - - styles [ - "card" - ] - - Label { - label: "q"; - valign: center; - margin-start: 12; - margin-end: 12; - margin-top: 12; - margin-bottom: 12; - yalign: 0; - - styles [ - "key-number", - ] - } - } - - Label { - label: "10%"; - margin-top: 6; - - styles [ - "error" - ] - } - } - - Box { - orientation: vertical; - - Adw.Bin { - width-request: 75; - - styles [ - "card" - ] - - Label { - label: "x"; - valign: center; - margin-start: 12; - margin-end: 12; - margin-top: 12; - margin-bottom: 12; - yalign: 0; - - styles [ - "key-number", - ] - } - } - - Label { - label: "10%"; - margin-top: 6; - - styles [ - "error" - ] - } - } - - Box { - orientation: vertical; - - Adw.Bin { - width-request: 75; - - styles [ - "card" - ] - - Label { - label: "å"; - valign: center; - margin-start: 12; - margin-end: 12; - margin-top: 12; - margin-bottom: 12; - yalign: 0; - - styles [ - "key-number", - ] - } - } - - Label { - label: "10%"; - margin-top: 6; - - styles [ - "error" - ] - } - } - - Box { - orientation: vertical; - - Adw.Bin { - width-request: 80; - - styles [ - "card" - ] - - Label { - label: "z"; - valign: center; - margin-start: 12; - margin-end: 12; - margin-top: 12; - margin-bottom: 12; - yalign: 0; - - styles [ - "key-number", - ] - } - } - - Label { - label: "10%"; - margin-top: 6; - - styles [ - "error" - ] - } - } - - Box { - orientation: vertical; - - Adw.Bin { - width-request: 80; - - styles [ - "card" - ] - - Label { - label: "a"; - valign: center; - margin-start: 12; - margin-end: 12; - margin-top: 12; - margin-bottom: 12; - yalign: 0; - - styles [ - "key-number", - ] - } - } - - Label { - label: "10%"; - margin-top: 6; - - styles [ - "error" - ] - } - } - - } - // PRs Label { label: _("Personal Records"); @@ -646,7 +425,7 @@ template $KpStatisticsDialog: Adw.Dialog { } - // Missed Characters + // Data Management Label { label: _("Data Management"); xalign: 0;