diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7ce64e4..1a6623f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -17,10 +17,10 @@ jobs: - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 - - name: Install GTK dependencies + - name: Install GTK and gettext dependencies run: | sudo apt-get update - sudo apt-get install -y libgtk-4-dev libadwaita-1-dev + sudo apt-get install -y libgtk-4-dev libadwaita-1-dev gettext - name: Check formatting run: cargo fmt --check diff --git a/assets/org.samrewritten.SamRewritten.gschema.xml b/assets/org.samrewritten.SamRewritten.gschema.xml index 0213147..4e0c3be 100644 --- a/assets/org.samrewritten.SamRewritten.gschema.xml +++ b/assets/org.samrewritten.SamRewritten.gschema.xml @@ -85,6 +85,18 @@ How the achievement view commits toggles. Valid values are 'autocommit' (each switch commits immediately), 'deferred' (toggles build an ordered queue that the user fires later), and 'copytiming' (replay a friend's unlock cadence). + + 'global-percentage' + Achievement Order + How achievements are sorted. Valid values are 'steam-default', 'alphabetical', 'global-percentage', and 'unlock-date'. + + + + 'steam-default' + Stat Order + How stats are sorted. Valid values are 'steam-default' and 'alphabetical'. + + 5 Copy Timing Maximum Gap (Minutes) @@ -157,4 +169,4 @@ Whether games you placed in Steam's own Hidden collection are left out of the games list. - \ No newline at end of file + diff --git a/po/fr.po b/po/fr.po index cfbb9a2..f24a569 100644 --- a/po/fr.po +++ b/po/fr.po @@ -2,114 +2,154 @@ msgid "" msgstr "" "Project-Id-Version: SamRewritten\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 21:40+0200\n" +"POT-Creation-Date: 2026-09-03 21:30+0200\n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: src/gui_frontend/ui_components.rs:159 src/gui_frontend/app_view.rs:62 +#: src/gui_frontend/ui_components.rs:160 src/gui_frontend/app_view.rs:62 #: src/gui_frontend/app_list_view/mod.rs:421 msgid "Loading..." msgstr "Chargement..." -#: src/gui_frontend/ui_components.rs:190 +#: src/gui_frontend/ui_components.rs:191 msgid "Select all visible apps" msgstr "Sélectionner les applications visibles" -#: src/gui_frontend/ui_components.rs:194 +#: src/gui_frontend/ui_components.rs:195 msgid "Deselect all apps" msgstr "Tout désélectionner" -#: src/gui_frontend/ui_components.rs:198 +#: src/gui_frontend/ui_components.rs:199 msgid "Unlock all in selection" msgstr "Tout déverrouiller dans la sélection" -#: src/gui_frontend/ui_components.rs:202 +#: src/gui_frontend/ui_components.rs:203 msgid "Reset all in selection" msgstr "Tout réinitialiser dans la sélection" -#: src/gui_frontend/ui_components.rs:206 +#: src/gui_frontend/ui_components.rs:207 #: src/gui_frontend/app_list_view/progress_actions.rs:116 msgid "Export selected apps progress" msgstr "Exporter la progression sélection" -#: src/gui_frontend/ui_components.rs:210 +#: src/gui_frontend/ui_components.rs:211 msgid "Import progress..." msgstr "Importer progression..." -#: src/gui_frontend/ui_components.rs:216 +#: src/gui_frontend/ui_components.rs:217 msgid "Appear in-game while managing" msgstr "Apparaître in-game pendant l'édition" -#: src/gui_frontend/ui_components.rs:222 +#: src/gui_frontend/ui_components.rs:223 msgid "Refresh app list" msgstr "Actualiser la liste des apps" -#: src/gui_frontend/ui_components.rs:226 +#: src/gui_frontend/ui_components.rs:227 msgid "Refresh achievement counts" msgstr "Actualiser le décompte de succès" -#: src/gui_frontend/ui_components.rs:229 src/gui_frontend/ui_components.rs:413 +#: src/gui_frontend/ui_components.rs:230 src/gui_frontend/ui_components.rs:471 msgid "About" msgstr "À propos" -#: src/gui_frontend/ui_components.rs:233 +#: src/gui_frontend/ui_components.rs:234 msgid "Change Steam folder…" msgstr "Changer le dossier Steam…" -#: src/gui_frontend/ui_components.rs:240 +#: src/gui_frontend/ui_components.rs:241 msgid "Bulk process" msgstr "Traitement par lot" -#: src/gui_frontend/ui_components.rs:246 +#: src/gui_frontend/ui_components.rs:247 msgid "System" msgstr "Système" -#: src/gui_frontend/ui_components.rs:247 +#: src/gui_frontend/ui_components.rs:248 msgid "Light" msgstr "Clair" -#: src/gui_frontend/ui_components.rs:248 +#: src/gui_frontend/ui_components.rs:249 msgid "Dark" msgstr "Sombre" -#: src/gui_frontend/ui_components.rs:257 +#: src/gui_frontend/ui_components.rs:258 msgid "Disable animations" msgstr "Désactiver les animations" -#: src/gui_frontend/ui_components.rs:261 +#: src/gui_frontend/ui_components.rs:262 msgid "Appearance" msgstr "Apparence" -#: src/gui_frontend/ui_components.rs:267 +#: src/gui_frontend/ui_components.rs:268 msgid "System default" msgstr "Système par défaut" -#: src/gui_frontend/ui_components.rs:270 +#: src/gui_frontend/ui_components.rs:271 msgid "Language" msgstr "Langue" -#: src/gui_frontend/ui_components.rs:387 +#: src/gui_frontend/ui_components.rs:388 msgid "Game default" msgstr "Langue du jeu" -#: src/gui_frontend/ui_components.rs:406 +#: src/gui_frontend/ui_components.rs:411 msgid "Refresh achievements & stats" msgstr "Actualiser les succès et statistiques" -#: src/gui_frontend/ui_components.rs:410 +#: src/gui_frontend/ui_components.rs:415 msgid "Reset everything" msgstr "Tout réinitialiser" -#: src/gui_frontend/ui_components.rs:418 +#: src/gui_frontend/ui_components.rs:435 msgid "Achievement language" msgstr "Langue des succès" +#: src/gui_frontend/ui_components.rs:440 +msgid "Achievement order" +msgstr "Tri des succès" + +#: src/gui_frontend/ui_components.rs:443 src/gui_frontend/ui_components.rs:465 +msgid "Steam default" +msgstr "Par défaut Steam" + +#: src/gui_frontend/ui_components.rs:444 src/gui_frontend/ui_components.rs:466 +msgid "Alphabetically" +msgstr "Alphabétique" + +#: src/gui_frontend/ui_components.rs:445 +msgid "Global percentage" +msgstr "Pourcentage global" + +#: src/gui_frontend/ui_components.rs:446 +msgid "Unlock date" +msgstr "Date de déblocage" + +#: src/gui_frontend/ui_components.rs:451 +msgid "Achievement state" +msgstr "Filtre des succès" + +#: src/gui_frontend/ui_components.rs:454 +msgid "All" +msgstr "Tous" + +#: src/gui_frontend/ui_components.rs:455 +msgid "Locked" +msgstr "Verrouillés" + +#: src/gui_frontend/ui_components.rs:456 +msgid "Unlocked" +msgstr "Déverrouillés" + +#: src/gui_frontend/ui_components.rs:462 +msgid "Stat order" +msgstr "Tri des stats" + #: src/gui_frontend/dialogs.rs:89 src/gui_frontend/dialogs.rs:162 #: src/gui_frontend/dialogs.rs:185 src/gui_frontend/dialogs.rs:233 -#: src/gui_frontend/unlock_scheduler.rs:185 +#: src/gui_frontend/unlock_scheduler.rs:217 #: src/gui_frontend/app_list_view/progress_actions.rs:43 msgid "OK" msgstr "OK" @@ -293,11 +333,11 @@ msgstr "Succès" msgid "Stats" msgstr "Statistiques" -#: src/gui_frontend/stat_view.rs:102 +#: src/gui_frontend/stat_view.rs:166 msgid "Increment only" msgstr "Incrément uniquement" -#: src/gui_frontend/stat_view.rs:107 +#: src/gui_frontend/stat_view.rs:171 msgid "This statistic is protected." msgstr "Cette statistique est protégée." @@ -305,19 +345,19 @@ msgstr "Cette statistique est protégée." msgid "Stop and go back" msgstr "Arrêter et revenir" -#: src/gui_frontend/achievement_manual_view/mod.rs:57 +#: src/gui_frontend/achievement_manual_view/mod.rs:58 msgid "No achievements staged" msgstr "Aucun succès préparé" -#: src/gui_frontend/achievement_manual_view/mod.rs:58 +#: src/gui_frontend/achievement_manual_view/mod.rs:59 msgid "{count} staged" msgstr "{count} préparé(s)" -#: src/gui_frontend/achievement_manual_view/mod.rs:96 +#: src/gui_frontend/achievement_manual_view/mod.rs:97 msgid "Already at or above target ({progress})" msgstr "Déjà à la cible ou au-dessus ({progress})" -#: src/gui_frontend/achievement_manual_view/mod.rs:102 +#: src/gui_frontend/achievement_manual_view/mod.rs:103 msgid "Auto-fill {count} achievement(s) ({progress})" msgstr "Remplir automatiquement {count} succès ({progress})" @@ -383,7 +423,7 @@ msgid "Spacing" msgstr "Espacement" #: src/gui_frontend/achievement_manual_view/copy_controls.rs:38 -#: src/gui_frontend/achievement_manual_view/copy_mode.rs:234 +#: src/gui_frontend/achievement_manual_view/copy_mode.rs:238 msgid "Choose a friend to copy timing from" msgstr "Choisir un ami pour lequel copier le minutage" @@ -404,11 +444,11 @@ msgstr "Écart max" msgid "First delay" msgstr "Délai initial" -#: src/gui_frontend/achievement_manual_view/copy_mode.rs:101 +#: src/gui_frontend/achievement_manual_view/copy_mode.rs:105 msgid "No friend loaded" msgstr "Aucun ami chargé" -#: src/gui_frontend/achievement_manual_view/copy_mode.rs:111 +#: src/gui_frontend/achievement_manual_view/copy_mode.rs:115 msgid "{count} achievements over {time}" msgstr "{count} succès sur {time}" @@ -449,148 +489,148 @@ msgid "Try again" msgstr "Réessayer" #: src/gui_frontend/app_list_view/mod.rs:464 -#: src/gui_frontend/app_list_view/mod.rs:1752 +#: src/gui_frontend/app_list_view/mod.rs:1769 msgid "Name or AppId (Ctrl+K)" msgstr "Nom ou AppId (Ctrl+K)" -#: src/gui_frontend/app_list_view/mod.rs:480 +#: src/gui_frontend/app_list_view/mod.rs:499 msgid "Show or hide the sidebar" msgstr "Afficher ou masquer la barre latérale" -#: src/gui_frontend/app_list_view/mod.rs:1747 +#: src/gui_frontend/app_list_view/mod.rs:1764 msgid "Achievement or stat..." msgstr "Succès ou statistique..." -#: src/gui_frontend/app_list_view/mod.rs:1866 -#: src/gui_frontend/app_list_view/settings_bindings.rs:333 +#: src/gui_frontend/app_list_view/mod.rs:1883 +#: src/gui_frontend/app_list_view/settings_bindings.rs:328 msgid "Could not change the in-game setting" msgstr "Impossible de changer l'option in-game" -#: src/gui_frontend/app_list_view/mod.rs:1868 -#: src/gui_frontend/app_list_view/settings_bindings.rs:334 +#: src/gui_frontend/app_list_view/mod.rs:1885 +#: src/gui_frontend/app_list_view/settings_bindings.rs:329 msgid "The change could not be applied. Restart SamRewritten and try again." msgstr "" "Le changement n'a pas pu être appliqué. Redémarrez SamRewritten et réessayez." -#: src/gui_frontend/app_list_view/sidebar.rs:58 +#: src/gui_frontend/app_list_view/sidebar.rs:57 msgid "Hide with no achievements" msgstr "Masquer sans succès" -#: src/gui_frontend/app_list_view/sidebar.rs:64 +#: src/gui_frontend/app_list_view/sidebar.rs:63 msgid "Hide at 100%" msgstr "Masquer à 100 %" -#: src/gui_frontend/app_list_view/sidebar.rs:70 +#: src/gui_frontend/app_list_view/sidebar.rs:69 msgid "Hide at 0%" msgstr "Masquer à 0 %" -#: src/gui_frontend/app_list_view/sidebar.rs:76 +#: src/gui_frontend/app_list_view/sidebar.rs:75 msgid "Hide never launched" msgstr "Masquer jamais lancés" -#: src/gui_frontend/app_list_view/sidebar.rs:82 +#: src/gui_frontend/app_list_view/sidebar.rs:81 msgid "Only currently idling" msgstr "Uniquement in-game" -#: src/gui_frontend/app_list_view/sidebar.rs:88 +#: src/gui_frontend/app_list_view/sidebar.rs:87 msgid "Hide hidden in Steam" msgstr "Masquer les masqués Steam" -#: src/gui_frontend/app_list_view/sidebar.rs:94 +#: src/gui_frontend/app_list_view/sidebar.rs:93 msgid "Show junk" msgstr "Afficher superflus" -#: src/gui_frontend/app_list_view/sidebar.rs:108 +#: src/gui_frontend/app_list_view/sidebar.rs:107 msgid "App ID" msgstr "App ID" -#: src/gui_frontend/app_list_view/sidebar.rs:113 +#: src/gui_frontend/app_list_view/sidebar.rs:112 msgid "Name" msgstr "Nom" -#: src/gui_frontend/app_list_view/sidebar.rs:118 +#: src/gui_frontend/app_list_view/sidebar.rs:117 msgid "Last played" msgstr "Lancé récemment" -#: src/gui_frontend/app_list_view/sidebar.rs:123 +#: src/gui_frontend/app_list_view/sidebar.rs:122 msgid "Playtime" msgstr "Temps de jeu" -#: src/gui_frontend/app_list_view/sidebar.rs:128 +#: src/gui_frontend/app_list_view/sidebar.rs:127 msgid "Completion" msgstr "Complétion" -#: src/gui_frontend/app_list_view/sidebar.rs:133 +#: src/gui_frontend/app_list_view/sidebar.rs:132 msgid "Achievements left" msgstr "Succès restants" -#: src/gui_frontend/app_list_view/sidebar.rs:183 +#: src/gui_frontend/app_list_view/sidebar.rs:180 msgid "Filters on a search term, which SamRewritten cannot reproduce exactly." msgstr "" "Filtre selon un terme de recherche, ce que SamRewritten ne peut pas " "reproduire exactement." -#: src/gui_frontend/app_list_view/sidebar.rs:186 +#: src/gui_frontend/app_list_view/sidebar.rs:183 msgid "Uses a Steam filter SamRewritten cannot reproduce exactly." msgstr "" "Utilise un filtre Steam que SamRewritten ne peut pas reproduire exactement." -#: src/gui_frontend/app_list_view/sidebar.rs:189 +#: src/gui_frontend/app_list_view/sidebar.rs:186 msgid "" "Needs information from Steam that could not be read. Refresh to try again." msgstr "" "Nécessite des informations de Steam qui n'ont pas pu être lues. Actualisez " "pour réessayer." -#: src/gui_frontend/app_list_view/sidebar.rs:192 +#: src/gui_frontend/app_list_view/sidebar.rs:189 msgid "" "Needs your friends' games, which Steam can only tell us when it is online." msgstr "" "Nécessite les jeux de vos amis, que Steam ne peut indiquer qu'en ligne." -#: src/gui_frontend/app_list_view/sidebar.rs:201 +#: src/gui_frontend/app_list_view/sidebar.rs:198 msgid "Favorites" msgstr "Favoris" -#: src/gui_frontend/app_list_view/sidebar.rs:228 +#: src/gui_frontend/app_list_view/sidebar.rs:225 msgid "View profile" msgstr "Voir le profil" -#: src/gui_frontend/app_list_view/sidebar.rs:283 +#: src/gui_frontend/app_list_view/sidebar.rs:280 #: src/gui_frontend/profile_view/mod.rs:484 msgid "Steam user" msgstr "Utilisateur Steam" -#: src/gui_frontend/app_list_view/sidebar.rs:305 +#: src/gui_frontend/app_list_view/sidebar.rs:300 msgid "Steam is offline. What needs its servers is turned off." msgstr "Steam est hors ligne. Ce qui dépend de ses serveurs est désactivé." -#: src/gui_frontend/app_list_view/sidebar.rs:316 +#: src/gui_frontend/app_list_view/sidebar.rs:311 msgid "Fetching completion…" msgstr "Chargement de la complétion…" -#: src/gui_frontend/app_list_view/sidebar.rs:322 +#: src/gui_frontend/app_list_view/sidebar.rs:317 msgid "Click to cancel" msgstr "Cliquez pour annuler" -#: src/gui_frontend/app_list_view/sidebar.rs:353 +#: src/gui_frontend/app_list_view/sidebar.rs:348 msgid "Filters" msgstr "Filtres" -#: src/gui_frontend/app_list_view/sidebar.rs:364 +#: src/gui_frontend/app_list_view/sidebar.rs:359 msgid "Steam collection" msgstr "Collection Steam" -#: src/gui_frontend/app_list_view/sidebar.rs:365 -#: src/gui_frontend/app_list_view/sidebar.rs:588 +#: src/gui_frontend/app_list_view/sidebar.rs:360 +#: src/gui_frontend/app_list_view/sidebar.rs:580 msgid "All games" msgstr "Tous les jeux" -#: src/gui_frontend/app_list_view/sidebar.rs:448 +#: src/gui_frontend/app_list_view/sidebar.rs:442 msgid "Sort by" msgstr "Trier par" -#: src/gui_frontend/app_list_view/sidebar.rs:513 +#: src/gui_frontend/app_list_view/sidebar.rs:505 msgid "Reset filters" msgstr "Réinitialiser les filtres" @@ -1360,9 +1400,6 @@ msgstr "Déjà déverrouillé." #~ msgid "Hide with no unlocks" #~ msgstr "Masquer sans succès obtenu" -#~ msgid "Alphabetical" -#~ msgstr "Alphabétique" - #~ msgid "Recently played" #~ msgstr "Lancé récemment" diff --git a/po/samrewritten.pot b/po/samrewritten.pot index 9778208..c2914d9 100644 --- a/po/samrewritten.pot +++ b/po/samrewritten.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: SamRewritten\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 21:40+0200\n" +"POT-Creation-Date: 2026-09-03 21:30+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,107 +17,147 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/gui_frontend/ui_components.rs:159 src/gui_frontend/app_view.rs:62 +#: src/gui_frontend/ui_components.rs:160 src/gui_frontend/app_view.rs:62 #: src/gui_frontend/app_list_view/mod.rs:421 msgid "Loading..." msgstr "" -#: src/gui_frontend/ui_components.rs:190 +#: src/gui_frontend/ui_components.rs:191 msgid "Select all visible apps" msgstr "" -#: src/gui_frontend/ui_components.rs:194 +#: src/gui_frontend/ui_components.rs:195 msgid "Deselect all apps" msgstr "" -#: src/gui_frontend/ui_components.rs:198 +#: src/gui_frontend/ui_components.rs:199 msgid "Unlock all in selection" msgstr "" -#: src/gui_frontend/ui_components.rs:202 +#: src/gui_frontend/ui_components.rs:203 msgid "Reset all in selection" msgstr "" -#: src/gui_frontend/ui_components.rs:206 +#: src/gui_frontend/ui_components.rs:207 #: src/gui_frontend/app_list_view/progress_actions.rs:116 msgid "Export selected apps progress" msgstr "" -#: src/gui_frontend/ui_components.rs:210 +#: src/gui_frontend/ui_components.rs:211 msgid "Import progress..." msgstr "" -#: src/gui_frontend/ui_components.rs:216 +#: src/gui_frontend/ui_components.rs:217 msgid "Appear in-game while managing" msgstr "" -#: src/gui_frontend/ui_components.rs:222 +#: src/gui_frontend/ui_components.rs:223 msgid "Refresh app list" msgstr "" -#: src/gui_frontend/ui_components.rs:226 +#: src/gui_frontend/ui_components.rs:227 msgid "Refresh achievement counts" msgstr "" -#: src/gui_frontend/ui_components.rs:229 src/gui_frontend/ui_components.rs:413 +#: src/gui_frontend/ui_components.rs:230 src/gui_frontend/ui_components.rs:471 msgid "About" msgstr "" -#: src/gui_frontend/ui_components.rs:233 +#: src/gui_frontend/ui_components.rs:234 msgid "Change Steam folder…" msgstr "" -#: src/gui_frontend/ui_components.rs:240 +#: src/gui_frontend/ui_components.rs:241 msgid "Bulk process" msgstr "" -#: src/gui_frontend/ui_components.rs:246 +#: src/gui_frontend/ui_components.rs:247 msgid "System" msgstr "" -#: src/gui_frontend/ui_components.rs:247 +#: src/gui_frontend/ui_components.rs:248 msgid "Light" msgstr "" -#: src/gui_frontend/ui_components.rs:248 +#: src/gui_frontend/ui_components.rs:249 msgid "Dark" msgstr "" -#: src/gui_frontend/ui_components.rs:257 +#: src/gui_frontend/ui_components.rs:258 msgid "Disable animations" msgstr "" -#: src/gui_frontend/ui_components.rs:261 +#: src/gui_frontend/ui_components.rs:262 msgid "Appearance" msgstr "" -#: src/gui_frontend/ui_components.rs:267 +#: src/gui_frontend/ui_components.rs:268 msgid "System default" msgstr "" -#: src/gui_frontend/ui_components.rs:270 +#: src/gui_frontend/ui_components.rs:271 msgid "Language" msgstr "" -#: src/gui_frontend/ui_components.rs:387 +#: src/gui_frontend/ui_components.rs:388 msgid "Game default" msgstr "" -#: src/gui_frontend/ui_components.rs:406 +#: src/gui_frontend/ui_components.rs:411 msgid "Refresh achievements & stats" msgstr "" -#: src/gui_frontend/ui_components.rs:410 +#: src/gui_frontend/ui_components.rs:415 msgid "Reset everything" msgstr "" -#: src/gui_frontend/ui_components.rs:418 +#: src/gui_frontend/ui_components.rs:435 msgid "Achievement language" msgstr "" +#: src/gui_frontend/ui_components.rs:440 +msgid "Achievement order" +msgstr "" + +#: src/gui_frontend/ui_components.rs:443 src/gui_frontend/ui_components.rs:465 +msgid "Steam default" +msgstr "" + +#: src/gui_frontend/ui_components.rs:444 src/gui_frontend/ui_components.rs:466 +msgid "Alphabetically" +msgstr "" + +#: src/gui_frontend/ui_components.rs:445 +msgid "Global percentage" +msgstr "" + +#: src/gui_frontend/ui_components.rs:446 +msgid "Unlock date" +msgstr "" + +#: src/gui_frontend/ui_components.rs:451 +msgid "Achievement state" +msgstr "" + +#: src/gui_frontend/ui_components.rs:454 +msgid "All" +msgstr "" + +#: src/gui_frontend/ui_components.rs:455 +msgid "Locked" +msgstr "" + +#: src/gui_frontend/ui_components.rs:456 +msgid "Unlocked" +msgstr "" + +#: src/gui_frontend/ui_components.rs:462 +msgid "Stat order" +msgstr "" + #: src/gui_frontend/dialogs.rs:89 src/gui_frontend/dialogs.rs:162 #: src/gui_frontend/dialogs.rs:185 src/gui_frontend/dialogs.rs:233 -#: src/gui_frontend/unlock_scheduler.rs:185 +#: src/gui_frontend/unlock_scheduler.rs:217 #: src/gui_frontend/app_list_view/progress_actions.rs:43 msgid "OK" msgstr "" @@ -274,11 +314,11 @@ msgstr "" msgid "Stats" msgstr "" -#: src/gui_frontend/stat_view.rs:102 +#: src/gui_frontend/stat_view.rs:166 msgid "Increment only" msgstr "" -#: src/gui_frontend/stat_view.rs:107 +#: src/gui_frontend/stat_view.rs:171 msgid "This statistic is protected." msgstr "" @@ -286,19 +326,19 @@ msgstr "" msgid "Stop and go back" msgstr "" -#: src/gui_frontend/achievement_manual_view/mod.rs:57 +#: src/gui_frontend/achievement_manual_view/mod.rs:58 msgid "No achievements staged" msgstr "" -#: src/gui_frontend/achievement_manual_view/mod.rs:58 +#: src/gui_frontend/achievement_manual_view/mod.rs:59 msgid "{count} staged" msgstr "" -#: src/gui_frontend/achievement_manual_view/mod.rs:96 +#: src/gui_frontend/achievement_manual_view/mod.rs:97 msgid "Already at or above target ({progress})" msgstr "" -#: src/gui_frontend/achievement_manual_view/mod.rs:102 +#: src/gui_frontend/achievement_manual_view/mod.rs:103 msgid "Auto-fill {count} achievement(s) ({progress})" msgstr "" @@ -364,7 +404,7 @@ msgid "Spacing" msgstr "" #: src/gui_frontend/achievement_manual_view/copy_controls.rs:38 -#: src/gui_frontend/achievement_manual_view/copy_mode.rs:234 +#: src/gui_frontend/achievement_manual_view/copy_mode.rs:238 msgid "Choose a friend to copy timing from" msgstr "" @@ -385,11 +425,11 @@ msgstr "" msgid "First delay" msgstr "" -#: src/gui_frontend/achievement_manual_view/copy_mode.rs:101 +#: src/gui_frontend/achievement_manual_view/copy_mode.rs:105 msgid "No friend loaded" msgstr "" -#: src/gui_frontend/achievement_manual_view/copy_mode.rs:111 +#: src/gui_frontend/achievement_manual_view/copy_mode.rs:115 msgid "{count} achievements over {time}" msgstr "" @@ -430,141 +470,141 @@ msgid "Try again" msgstr "" #: src/gui_frontend/app_list_view/mod.rs:464 -#: src/gui_frontend/app_list_view/mod.rs:1752 +#: src/gui_frontend/app_list_view/mod.rs:1769 msgid "Name or AppId (Ctrl+K)" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:480 +#: src/gui_frontend/app_list_view/mod.rs:499 msgid "Show or hide the sidebar" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1747 +#: src/gui_frontend/app_list_view/mod.rs:1764 msgid "Achievement or stat..." msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1866 -#: src/gui_frontend/app_list_view/settings_bindings.rs:333 +#: src/gui_frontend/app_list_view/mod.rs:1883 +#: src/gui_frontend/app_list_view/settings_bindings.rs:328 msgid "Could not change the in-game setting" msgstr "" -#: src/gui_frontend/app_list_view/mod.rs:1868 -#: src/gui_frontend/app_list_view/settings_bindings.rs:334 +#: src/gui_frontend/app_list_view/mod.rs:1885 +#: src/gui_frontend/app_list_view/settings_bindings.rs:329 msgid "The change could not be applied. Restart SamRewritten and try again." msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:58 +#: src/gui_frontend/app_list_view/sidebar.rs:57 msgid "Hide with no achievements" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:64 +#: src/gui_frontend/app_list_view/sidebar.rs:63 msgid "Hide at 100%" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:70 +#: src/gui_frontend/app_list_view/sidebar.rs:69 msgid "Hide at 0%" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:76 +#: src/gui_frontend/app_list_view/sidebar.rs:75 msgid "Hide never launched" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:82 +#: src/gui_frontend/app_list_view/sidebar.rs:81 msgid "Only currently idling" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:88 +#: src/gui_frontend/app_list_view/sidebar.rs:87 msgid "Hide hidden in Steam" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:94 +#: src/gui_frontend/app_list_view/sidebar.rs:93 msgid "Show junk" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:108 +#: src/gui_frontend/app_list_view/sidebar.rs:107 msgid "App ID" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:113 +#: src/gui_frontend/app_list_view/sidebar.rs:112 msgid "Name" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:118 +#: src/gui_frontend/app_list_view/sidebar.rs:117 msgid "Last played" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:123 +#: src/gui_frontend/app_list_view/sidebar.rs:122 msgid "Playtime" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:128 +#: src/gui_frontend/app_list_view/sidebar.rs:127 msgid "Completion" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:133 +#: src/gui_frontend/app_list_view/sidebar.rs:132 msgid "Achievements left" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:183 +#: src/gui_frontend/app_list_view/sidebar.rs:180 msgid "Filters on a search term, which SamRewritten cannot reproduce exactly." msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:186 +#: src/gui_frontend/app_list_view/sidebar.rs:183 msgid "Uses a Steam filter SamRewritten cannot reproduce exactly." msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:189 +#: src/gui_frontend/app_list_view/sidebar.rs:186 msgid "" "Needs information from Steam that could not be read. Refresh to try again." msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:192 +#: src/gui_frontend/app_list_view/sidebar.rs:189 msgid "" "Needs your friends' games, which Steam can only tell us when it is online." msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:201 +#: src/gui_frontend/app_list_view/sidebar.rs:198 msgid "Favorites" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:228 +#: src/gui_frontend/app_list_view/sidebar.rs:225 msgid "View profile" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:283 +#: src/gui_frontend/app_list_view/sidebar.rs:280 #: src/gui_frontend/profile_view/mod.rs:484 msgid "Steam user" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:305 +#: src/gui_frontend/app_list_view/sidebar.rs:300 msgid "Steam is offline. What needs its servers is turned off." msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:316 +#: src/gui_frontend/app_list_view/sidebar.rs:311 msgid "Fetching completion…" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:322 +#: src/gui_frontend/app_list_view/sidebar.rs:317 msgid "Click to cancel" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:353 +#: src/gui_frontend/app_list_view/sidebar.rs:348 msgid "Filters" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:364 +#: src/gui_frontend/app_list_view/sidebar.rs:359 msgid "Steam collection" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:365 -#: src/gui_frontend/app_list_view/sidebar.rs:588 +#: src/gui_frontend/app_list_view/sidebar.rs:360 +#: src/gui_frontend/app_list_view/sidebar.rs:580 msgid "All games" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:448 +#: src/gui_frontend/app_list_view/sidebar.rs:442 msgid "Sort by" msgstr "" -#: src/gui_frontend/app_list_view/sidebar.rs:513 +#: src/gui_frontend/app_list_view/sidebar.rs:505 msgid "Reset filters" msgstr "" diff --git a/src/gui_frontend/achievement_manual_view/copy_mode.rs b/src/gui_frontend/achievement_manual_view/copy_mode.rs index efd2ff6..e20df9a 100644 --- a/src/gui_frontend/achievement_manual_view/copy_mode.rs +++ b/src/gui_frontend/achievement_manual_view/copy_mode.rs @@ -23,7 +23,9 @@ use crate::gui_frontend::gobjects::mode_state::{GUnlockModeState, MODE_COPY_TIMI use crate::gui_frontend::i18n::tr; use crate::gui_frontend::request::{GetFriendUnlockTimes, GetFriends, GetUserAvatar, Request}; use crate::gui_frontend::unlock_queue::UnlockQueue; -use crate::gui_frontend::unlock_scheduler::{compute_copy_timing_ms, run_timed_unlock}; +use crate::gui_frontend::unlock_scheduler::{ + AchievementModelUpdates, compute_copy_timing_ms, run_timed_unlock, +}; use crate::gui_frontend::widgets::shimmer_image::ShimmerImage; use crate::utils::action_journal::Op; use crate::utils::format::format_seconds_to_hh_mm_ss; @@ -72,6 +74,7 @@ pub(super) fn install_copy_mode( raw_model: &ListStore, timed_raw_model: &ListStore, cancelled_task: &Arc, + model_updates: &AchievementModelUpdates, achievement_views_stack: &Stack, application: &MainApplication, ) { @@ -82,6 +85,7 @@ pub(super) fn install_copy_mode( let raw_model = raw_model.clone(); let timed_raw_model = timed_raw_model.clone(); let cancelled_task = cancelled_task.clone(); + let model_updates = model_updates.clone(); let achievement_views_stack = achievement_views_stack.clone(); let application = application.clone(); @@ -389,6 +393,8 @@ pub(super) fn install_copy_mode( #[strong] cancelled_task, #[strong] + model_updates, + #[strong] timed_raw_model, #[weak(rename_to = raw_model)] raw_model, @@ -425,6 +431,8 @@ pub(super) fn install_copy_mode( timed_raw_model, #[strong] cancelled_task, + #[strong] + model_updates, async move { run_timed_unlock( app_id_val, @@ -433,6 +441,7 @@ pub(super) fn install_copy_mode( times_ms, timed_raw_model, cancelled_task, + model_updates, ) .await; } diff --git a/src/gui_frontend/achievement_manual_view/mod.rs b/src/gui_frontend/achievement_manual_view/mod.rs index 6fa4646..af4d57f 100644 --- a/src/gui_frontend/achievement_manual_view/mod.rs +++ b/src/gui_frontend/achievement_manual_view/mod.rs @@ -30,7 +30,8 @@ use crate::gui_frontend::gsettings::get_settings; use crate::gui_frontend::i18n::tr; use crate::gui_frontend::unlock_queue::{UnlockQueue, resolve_target_count}; use crate::gui_frontend::unlock_scheduler::{ - SPACING_EVEN, SPACING_RANDOM, compute_unlock_times_ms, run_timed_unlock, unlock_all_immediately, + AchievementModelUpdates, SPACING_EVEN, SPACING_RANDOM, compute_unlock_times_ms, + run_timed_unlock, unlock_all_immediately, }; use crate::utils::action_journal::Op; use crate::utils::format::format_achievement_progress; @@ -111,6 +112,7 @@ pub fn create_achievements_manual_view( app_id: &Rc>>, app_unlocked_achievements_count: &Rc>, filtered_model: &NoSelection, + model_updates: &AchievementModelUpdates, raw_model: &ListStore, timed_raw_model: &ListStore, achievement_views_stack: &Stack, @@ -118,6 +120,7 @@ pub fn create_achievements_manual_view( application: &MainApplication, ) -> (Frame, Arc) { let settings = get_settings(); + let model_updates = model_updates.clone(); let mode_state = Rc::new(GUnlockModeState::default()); let initial_mode = settings.string("unlock-mode").to_string(); @@ -337,6 +340,8 @@ pub fn create_achievements_manual_view( cancelled_task, #[strong] timed_raw_model, + #[strong] + model_updates, #[weak] application, #[weak(rename_to = raw_model)] @@ -386,6 +391,8 @@ pub fn create_achievements_manual_view( timed_raw_model, #[strong] cancelled_task, + #[strong] + model_updates, async move { run_timed_unlock( app_id_val, @@ -394,6 +401,7 @@ pub fn create_achievements_manual_view( times_ms, timed_raw_model, cancelled_task, + model_updates, ) .await; } @@ -413,6 +421,7 @@ pub fn create_achievements_manual_view( raw_model, timed_raw_model, &cancelled_task, + &model_updates, achievement_views_stack, application, ); @@ -430,6 +439,7 @@ pub fn create_achievements_manual_view( &header.queue_label, &cancelled_task, &update_autofill, + &model_updates, ); let app_achievements_list_view = ListView::builder() diff --git a/src/gui_frontend/achievement_manual_view/row_factory.rs b/src/gui_frontend/achievement_manual_view/row_factory.rs index ddd649b..30b32b2 100644 --- a/src/gui_frontend/achievement_manual_view/row_factory.rs +++ b/src/gui_frontend/achievement_manual_view/row_factory.rs @@ -19,6 +19,7 @@ use crate::gui_frontend::gobjects::achievement::GAchievementObject; use crate::gui_frontend::gobjects::mode_state::GUnlockModeState; use crate::gui_frontend::request::{Request, SetAchievement}; use crate::gui_frontend::unlock_queue::UnlockQueue; +use crate::gui_frontend::unlock_scheduler::AchievementModelUpdates; use crate::gui_frontend::widgets::achievement_row::AchievementRow; use crate::utils::action_journal::{Batch, Change, Op}; use crate::utils::format::format_achievement_progress; @@ -44,6 +45,7 @@ pub(super) fn install_row_factory( queue_label: &Label, cancelled_task: &Arc, update_autofill: &Rc, + model_updates: &AchievementModelUpdates, ) { factory.connect_setup(clone!( #[strong] @@ -58,6 +60,8 @@ pub(super) fn install_row_factory( cancelled_task, #[strong] update_autofill, + #[strong] + model_updates, #[weak] raw_model, #[weak] @@ -93,6 +97,8 @@ pub(super) fn install_row_factory( raw_model, #[weak] start_button, + #[strong] + model_updates, move |switch| { let Some(achievement_object) = list_item.item().and_downcast::() @@ -143,12 +149,21 @@ pub(super) fn install_row_factory( achievement_object, #[weak] start_button, + #[strong] + model_updates, async move { let result = handle.await.expect("spawn_blocking task panicked"); // Steam accepting the call and then failing to // store it, which is a failure like any other. match result { Ok(true) => { + achievement_object.set_unlock_time_seconds(if unlocked { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) + } else { + 0 + }); Batch::new(Op::ManualToggle, app_id_val, "").record(vec![ Change::Achievement { id: achievement_object.id(), @@ -180,6 +195,7 @@ pub(super) fn install_row_factory( } } switch.set_sensitive(true); + model_updates.changed(); } )); } diff --git a/src/gui_frontend/achievement_view.rs b/src/gui_frontend/achievement_view.rs index 2c074ad..fbd38b6 100644 --- a/src/gui_frontend/achievement_view.rs +++ b/src/gui_frontend/achievement_view.rs @@ -17,11 +17,14 @@ use crate::gui_frontend::MainApplication; use crate::gui_frontend::achievement_automatic_view::create_achievements_automatic_view; use crate::gui_frontend::achievement_manual_view::create_achievements_manual_view; use crate::gui_frontend::gobjects::achievement::GAchievementObject; -use gtk::gio::ListStore; +use crate::gui_frontend::gsettings::get_settings; +use crate::gui_frontend::unlock_scheduler::AchievementModelUpdates; +use gtk::gio::{ListStore, SimpleAction}; +use gtk::glib; use gtk::prelude::*; use gtk::{ - CustomSorter, FilterListModel, Label, NoSelection, SortListModel, Stack, StackTransitionType, - StringFilter, StringFilterMatchMode, + CustomFilter, CustomSorter, FilterChange, FilterListModel, Label, NoSelection, SortListModel, + SorterChange, Stack, StackTransitionType, StringFilter, StringFilterMatchMode, }; use std::cell::Cell; use std::cmp::Ordering; @@ -29,12 +32,43 @@ use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; +#[derive(Clone, Copy, Default)] +enum AchievementOrder { + SteamDefault, + Alphabetical, + #[default] + GlobalPercentage, + UnlockDate, +} + +impl AchievementOrder { + fn from_action_target(value: &str) -> Option { + match value { + "steam-default" => Some(Self::SteamDefault), + "alphabetical" => Some(Self::Alphabetical), + "global-percentage" => Some(Self::GlobalPercentage), + "unlock-date" => Some(Self::UnlockDate), + _ => None, + } + } + + fn as_action_target(self) -> &'static str { + match self { + Self::SteamDefault => "steam-default", + Self::Alphabetical => "alphabetical", + Self::GlobalPercentage => "global-percentage", + Self::UnlockDate => "unlock-date", + } + } +} + pub fn create_achievements_view( app_id: Rc>>, app_unlocked_achievements_count: Rc>, application: &MainApplication, app_achievement_count_value: &Label, ) -> (Stack, ListStore, StringFilter, Arc) { + let settings = get_settings(); let app_achievements_model = ListStore::new::(); let app_timed_achievements_model = ListStore::new::(); @@ -47,27 +81,126 @@ pub fn create_achievements_view( .model(&app_achievements_model) .filter(&app_achievement_string_filter) .build(); + // None shows all achievements; Some(false) and Some(true) show only + // locked and unlocked achievements respectively. + let achievement_status = Rc::new(Cell::new(None)); + let achievement_status_filter = CustomFilter::new({ + let achievement_status = Rc::clone(&achievement_status); + move |object| { + let achievement = object.downcast_ref::().unwrap(); + achievement_status + .get() + .is_none_or(|unlocked| achievement.is_achieved() == unlocked) + } + }); + let app_achievement_status_filter_model = FilterListModel::builder() + .model(&app_achievement_filter_model) + .filter(&achievement_status_filter) + .build(); let app_achievement_timed_filter_model = FilterListModel::builder() .model(&app_timed_achievements_model) .filter(&app_achievement_string_filter) .build(); - let global_achieved_percent_sorter = CustomSorter::new(move |obj1, obj2| { - let achievement1 = obj1.downcast_ref::().unwrap(); - let achievement2 = obj2.downcast_ref::().unwrap(); - - let percent1 = achievement1.global_achieved_percent(); - let percent2 = achievement2.global_achieved_percent(); - - percent2 - .partial_cmp(&percent1) - .unwrap_or(Ordering::Equal) - .into() + let initial_order = AchievementOrder::from_action_target(&settings.string("achievement-order")) + .unwrap_or_default(); + let achievement_order = Rc::new(Cell::new(initial_order)); + let achievement_sorter = CustomSorter::new({ + let achievement_order = Rc::clone(&achievement_order); + move |obj1, obj2| { + let achievement1 = obj1.downcast_ref::().unwrap(); + let achievement2 = obj2.downcast_ref::().unwrap(); + match achievement_order.get() { + AchievementOrder::SteamDefault => Ordering::Equal.into(), + AchievementOrder::Alphabetical => achievement1 + .name() + .to_lowercase() + .cmp(&achievement2.name().to_lowercase()) + .into(), + AchievementOrder::UnlockDate => achievement2 + .unlock_time_seconds() + .cmp(&achievement1.unlock_time_seconds()) + .into(), + AchievementOrder::GlobalPercentage => achievement2 + .global_achieved_percent() + .partial_cmp(&achievement1.global_achieved_percent()) + .unwrap_or(Ordering::Equal) + .into(), + } + } }); let app_achievement_sort_model = SortListModel::builder() - .model(&app_achievement_filter_model) - .sorter(&global_achieved_percent_sorter) + .model(&app_achievement_status_filter_model) + .sorter(&achievement_sorter) .build(); + if matches!(initial_order, AchievementOrder::SteamDefault) { + app_achievement_sort_model.set_sorter(None::<&CustomSorter>); + } + let achievement_model_updates = + AchievementModelUpdates::new(&achievement_status_filter, &achievement_sorter); + + let order_action = SimpleAction::new_stateful( + "achievement-order", + Some(&String::static_variant_type()), + &initial_order.as_action_target().to_variant(), + ); + order_action.connect_activate(glib::clone!( + #[strong] + achievement_order, + #[strong] + achievement_sorter, + #[strong] + settings, + #[weak(rename_to = sort_model)] + app_achievement_sort_model, + move |action, target| { + let Some(value) = target.and_then(|target| target.str()) else { + return; + }; + let Some(order) = AchievementOrder::from_action_target(value) else { + return; + }; + action.set_state(&value.to_variant()); + if let Err(e) = settings.set_string("achievement-order", value) { + eprintln!("[CLIENT] Error saving achievement order: {e:?}"); + } + achievement_order.set(order); + if matches!(order, AchievementOrder::SteamDefault) { + sort_model.set_sorter(None::<&CustomSorter>); + } else { + sort_model.set_sorter(Some(&achievement_sorter)); + achievement_sorter.changed(SorterChange::Different); + } + } + )); + application.add_action(&order_action); + + let state_action = SimpleAction::new_stateful( + "achievement-state", + Some(&String::static_variant_type()), + &"all".to_variant(), + ); + state_action.connect_activate(glib::clone!( + #[strong] + achievement_status, + #[weak] + achievement_status_filter, + move |action, target| { + let Some(value) = target.and_then(|target| target.str()) else { + return; + }; + let state = match value { + "all" => None, + "locked" => Some(false), + "unlocked" => Some(true), + _ => return, + }; + action.set_state(&value.to_variant()); + achievement_status.set(state); + achievement_status_filter.changed(FilterChange::Different); + } + )); + application.add_action(&state_action); let app_achievement_selection_model = NoSelection::new(Option::::None); app_achievement_selection_model.set_model(Some(&app_achievement_sort_model)); @@ -81,6 +214,7 @@ pub fn create_achievements_view( &app_id, &app_unlocked_achievements_count, &app_achievement_selection_model, + &achievement_model_updates, &app_achievements_model, &app_timed_achievements_model, &achievement_views_stack, @@ -100,3 +234,13 @@ pub fn create_achievements_view( cancel_timed_unlock, ) } + +#[cfg(test)] +mod tests { + use super::AchievementOrder; + + #[test] + fn achievement_order_rejects_unknown_action_targets() { + assert!(AchievementOrder::from_action_target("unknown").is_none()); + } +} diff --git a/src/gui_frontend/app_list_view/mod.rs b/src/gui_frontend/app_list_view/mod.rs index 581a971..d03a0ae 100644 --- a/src/gui_frontend/app_list_view/mod.rs +++ b/src/gui_frontend/app_list_view/mod.rs @@ -43,8 +43,8 @@ use crate::gui_frontend::request::{ AppProgress, GetCollections, GetRunningApps, LaunchApp, Request, SetStealthMode, StopApp, }; use crate::gui_frontend::ui_components::{ - create_context_menu_button, set_context_popover_to_app_list_context, - set_context_popover_to_profile_context, + create_context_menu_button, set_app_details_view_options, + set_context_popover_to_app_list_context, set_context_popover_to_profile_context, }; use crate::gui_frontend::widgets::steam_app_card::{CARD_HEIGHT, CARD_MIN_WIDTH, SteamAppCard}; use crate::utils::action_journal; @@ -393,8 +393,8 @@ pub fn create_main_ui( app_stack, app_shimmer_image, app_label, - _app_achievements_button, - _app_stats_button, + app_achievements_button, + app_stats_button, app_achievement_count_value, app_stats_count_value, app_type_value, @@ -475,6 +475,25 @@ pub fn create_main_ui( context_menu_button_loading_progress_label, context_menu_button_info_label, ) = create_context_menu_button(); + app_achievements_button.connect_clicked(clone!( + #[weak] + menu_model, + move |_| set_app_details_view_options(&menu_model, true) + )); + app_stats_button.connect_clicked(clone!( + #[weak] + menu_model, + move |_| set_app_details_view_options(&menu_model, false) + )); + app_stack.connect_visible_child_name_notify(clone!( + #[weak] + menu_model, + move |stack| match stack.visible_child_name().as_deref() { + Some("achievements") => set_app_details_view_options(&menu_model, true), + Some("stats") => set_app_details_view_options(&menu_model, false), + _ => {} + } + )); let sidebar_button = ToggleButton::builder() .icon_name("sidebar-show-symbolic") .tooltip_text(tr("Show or hide the sidebar").as_str()) diff --git a/src/gui_frontend/app_view.rs b/src/gui_frontend/app_view.rs index 1315f3d..160338b 100644 --- a/src/gui_frontend/app_view.rs +++ b/src/gui_frontend/app_view.rs @@ -233,7 +233,8 @@ pub fn create_app_view( &app_achievement_count_value, ); - let (app_stat_scrolled_window, app_stat_model, app_stat_string_filter) = create_stats_view(); + let (app_stat_scrolled_window, app_stat_model, app_stat_string_filter) = + create_stats_view(application); let app_stack = Stack::builder() .transition_type(StackTransitionType::SlideLeftRight) diff --git a/src/gui_frontend/gobjects/achievement.rs b/src/gui_frontend/gobjects/achievement.rs index f148b91..aa2ef4b 100644 --- a/src/gui_frontend/gobjects/achievement.rs +++ b/src/gui_frontend/gobjects/achievement.rs @@ -15,6 +15,7 @@ use glib::Object; use gtk::glib; +use std::time::UNIX_EPOCH; use crate::backend::stat_definitions::AchievementInfo; @@ -26,6 +27,10 @@ impl GAchievementObject { pub fn new(info: AchievementInfo) -> Self { let global_achieved_percent = info.global_achieved_percent.unwrap_or(0.0); let global_achieved_percent_ok = info.global_achieved_percent.is_some(); + let unlock_time_seconds = info + .unlock_time + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |duration| duration.as_secs()); Object::builder() .property("search-text", format!("{} {}", info.name, info.description)) @@ -37,6 +42,7 @@ impl GAchievementObject { "unlock-time", info.unlock_time.map(|time| format!("{time:#?}")), ) + .property("unlock-time-seconds", unlock_time_seconds) .property("icon-normal", info.icon_normal) .property("icon-locked", info.icon_locked) .property("permission", info.permission) @@ -76,6 +82,9 @@ mod imp { #[property(get, set)] unlock_time: RefCell>, + #[property(get, set)] + unlock_time_seconds: Cell, + #[property(get, set)] icon_normal: RefCell, diff --git a/src/gui_frontend/stat_view.rs b/src/gui_frontend/stat_view.rs index 98f3cc6..9bd5993 100644 --- a/src/gui_frontend/stat_view.rs +++ b/src/gui_frontend/stat_view.rs @@ -15,27 +15,55 @@ use super::gobjects::stat::GStatObject; use super::request::{Request, SetFloatStat, SetIntStat}; +use crate::gui_frontend::MainApplication; +use crate::gui_frontend::gsettings::get_settings; use crate::gui_frontend::i18n::tr; use crate::utils::action_journal::{Batch, Change, Op}; -use gtk::gio::{ListStore, spawn_blocking}; +use gtk::gio::prelude::{ActionMapExt, SettingsExt}; +use gtk::gio::{ListStore, SimpleAction, spawn_blocking}; use gtk::glib::SignalHandlerId; use gtk::glib::object::Cast; +use gtk::glib::prelude::{StaticVariantType, ToVariant}; use gtk::glib::translate::FromGlib; use gtk::pango::EllipsizeMode; use gtk::prelude::{ BoxExt, GObjectPropertyExpressionExt, ListItemExt, ObjectExt, ToValue, WidgetExt, }; use gtk::{ - Adjustment, Align, Box, ClosureExpression, FilterListModel, Frame, Label, ListItem, ListView, - NoSelection, Orientation, ScrolledWindow, SignalListItemFactory, SpinButton, StringFilter, - StringFilterMatchMode, Widget, glib, + Adjustment, Align, Box, ClosureExpression, CustomSorter, FilterListModel, Frame, Label, + ListItem, ListView, NoSelection, Orientation, ScrolledWindow, SignalListItemFactory, + SortListModel, SpinButton, StringFilter, StringFilterMatchMode, Widget, glib, }; use std::cell::RefCell; use std::ffi::c_ulong; use std::sync::mpsc::channel; use std::time::Duration; -pub fn create_stats_view() -> (Frame, ListStore, StringFilter) { +#[derive(Clone, Copy)] +enum StatOrder { + SteamDefault, + Alphabetical, +} + +impl StatOrder { + fn from_action_target(value: &str) -> Option { + match value { + "steam-default" => Some(Self::SteamDefault), + "alphabetical" => Some(Self::Alphabetical), + _ => None, + } + } + + fn as_action_target(self) -> &'static str { + match self { + Self::SteamDefault => "steam-default", + Self::Alphabetical => "alphabetical", + } + } +} + +pub fn create_stats_view(application: &MainApplication) -> (Frame, ListStore, StringFilter) { + let settings = get_settings(); let stats_list_factory = SignalListItemFactory::new(); let app_stats_model = ListStore::new::(); @@ -48,8 +76,58 @@ pub fn create_stats_view() -> (Frame, ListStore, StringFilter) { .model(&app_stats_model) .filter(&app_stats_string_filter) .build(); + + let stat_sorter = CustomSorter::new(|obj1, obj2| { + let stat1 = obj1.downcast_ref::().unwrap(); + let stat2 = obj2.downcast_ref::().unwrap(); + stat1 + .display_name() + .to_lowercase() + .cmp(&stat2.display_name().to_lowercase()) + .into() + }); + let app_stats_sort_model = SortListModel::builder() + .model(&app_stats_filter_model) + .build(); + let initial_order = StatOrder::from_action_target(&settings.string("stat-order")) + .unwrap_or(StatOrder::SteamDefault); + if matches!(initial_order, StatOrder::Alphabetical) { + app_stats_sort_model.set_sorter(Some(&stat_sorter)); + } + + let order_action = SimpleAction::new_stateful( + "stat-order", + Some(&String::static_variant_type()), + &initial_order.as_action_target().to_variant(), + ); + order_action.connect_activate(glib::clone!( + #[strong] + stat_sorter, + #[strong] + settings, + #[weak(rename_to = sort_model)] + app_stats_sort_model, + move |action, target| { + let Some(value) = target.and_then(|target| target.str()) else { + return; + }; + let Some(order) = StatOrder::from_action_target(value) else { + return; + }; + action.set_state(&value.to_variant()); + if let Err(e) = settings.set_string("stat-order", value) { + eprintln!("[CLIENT] Error saving stat order: {e:?}"); + } + match order { + StatOrder::SteamDefault => sort_model.set_sorter(None::<&CustomSorter>), + StatOrder::Alphabetical => sort_model.set_sorter(Some(&stat_sorter)), + } + } + )); + application.add_action(&order_action); + let app_stats_selection_model = NoSelection::new(Option::::None); - app_stats_selection_model.set_model(Some(&app_stats_filter_model)); + app_stats_selection_model.set_model(Some(&app_stats_sort_model)); let app_stats_list_view = ListView::builder() .orientation(Orientation::Vertical) @@ -366,3 +444,13 @@ pub fn create_stats_view() -> (Frame, ListStore, StringFilter) { (app_stats_frame, app_stats_model, app_stats_string_filter) } + +#[cfg(test)] +mod tests { + use super::StatOrder; + + #[test] + fn stat_order_rejects_unknown_action_targets() { + assert!(StatOrder::from_action_target("unknown").is_none()); + } +} diff --git a/src/gui_frontend/ui_components.rs b/src/gui_frontend/ui_components.rs index 55cc8aa..d526d36 100644 --- a/src/gui_frontend/ui_components.rs +++ b/src/gui_frontend/ui_components.rs @@ -16,6 +16,7 @@ use crate::gui_frontend::MainApplication; use crate::gui_frontend::application_actions::set_app_action_enabled; use crate::gui_frontend::i18n::{tr, tr_noop}; +use gtk::gio::prelude::MenuModelExt; use gtk::prelude::{BoxExt, SettingsExt, ToVariant}; use gtk::{Label, License, MenuButton, Popover, PopoverMenu, PositionType, Spinner}; @@ -397,6 +398,10 @@ fn fill_achievement_language_menu(languages: &[String]) { }); } +/// Entries `set_context_popover_to_app_details_context` puts above the +/// tab-specific portion, and therefore the first index the latter may clear. +const APP_DETAILS_FIXED_ITEMS: i32 = 2; + pub fn set_context_popover_to_app_details_context( menu_model: >k::gio::Menu, application: &MainApplication, @@ -410,13 +415,73 @@ pub fn set_context_popover_to_app_details_context( Some(tr("Reset everything").as_str()), Some("app.clear_all_stats_and_achievements"), ); - menu_model.append(Some(tr("About").as_str()), Some("app.about")); + debug_assert_eq!(menu_model.n_items(), APP_DETAILS_FIXED_ITEMS); ACHIEVEMENT_LANGUAGES_FROM_FETCH.with(|f| f.set(false)); fill_achievement_language_menu(&[]); - ACHIEVEMENT_LANGUAGE_MENU.with(|menu| { - menu_model.append_submenu(Some(tr("Achievement language").as_str()), menu); - }); + set_app_details_view_options(menu_model, true); set_app_action_enabled(application, "refresh_app_list", false); } + +/// Replace the tab-specific portion of the app-details menu. +pub fn set_app_details_view_options(menu_model: >k::gio::Menu, achievements: bool) { + while menu_model.n_items() > APP_DETAILS_FIXED_ITEMS { + menu_model.remove(APP_DETAILS_FIXED_ITEMS); + } + + if achievements { + ACHIEVEMENT_LANGUAGE_MENU.with(|menu| { + menu_model.append_submenu(Some(tr("Achievement language").as_str()), menu); + }); + + append_target_submenu( + menu_model, + tr("Achievement order").as_str(), + "app.achievement-order", + &[ + (tr("Steam default"), "steam-default"), + (tr("Alphabetically"), "alphabetical"), + (tr("Global percentage"), "global-percentage"), + (tr("Unlock date"), "unlock-date"), + ], + ); + append_target_submenu( + menu_model, + tr("Achievement state").as_str(), + "app.achievement-state", + &[ + (tr("All"), "all"), + (tr("Locked"), "locked"), + (tr("Unlocked"), "unlocked"), + ], + ); + } else { + append_target_submenu( + menu_model, + tr("Stat order").as_str(), + "app.stat-order", + &[ + (tr("Steam default"), "steam-default"), + (tr("Alphabetically"), "alphabetical"), + ], + ); + } + + menu_model.append(Some(tr("About").as_str()), Some("app.about")); +} + +fn append_target_submenu( + menu_model: >k::gio::Menu, + label: &str, + action: &str, + entries: &[(gtk::glib::GString, &str)], +) { + let submenu = gtk::gio::Menu::new(); + for (label, value) in entries { + let item = gtk::gio::MenuItem::new(Some(label.as_str()), None); + item.set_action_and_target_value(Some(action), Some(&value.to_variant())); + submenu.append_item(&item); + } + menu_model.append_submenu(Some(label), &submenu); +} diff --git a/src/gui_frontend/unlock_scheduler.rs b/src/gui_frontend/unlock_scheduler.rs index c77e7e3..1f3e781 100644 --- a/src/gui_frontend/unlock_scheduler.rs +++ b/src/gui_frontend/unlock_scheduler.rs @@ -21,6 +21,8 @@ use crate::utils::action_journal::{Batch, Change, Op}; use crate::utils::format::format_seconds_to_hh_mm_ss; use gtk::gio::{ListStore, spawn_blocking}; use gtk::glib; +use gtk::prelude::{FilterExt, SorterExt}; +use gtk::{CustomFilter, CustomSorter, FilterChange, SorterChange}; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -29,6 +31,26 @@ pub const SPACING_RANDOM: &str = "random"; const MIN_GAP_MS: u64 = 2_000; +#[derive(Clone)] +pub struct AchievementModelUpdates { + status_filter: CustomFilter, + sorter: CustomSorter, +} + +impl AchievementModelUpdates { + pub fn new(status_filter: &CustomFilter, sorter: &CustomSorter) -> Self { + Self { + status_filter: status_filter.clone(), + sorter: sorter.clone(), + } + } + + pub fn changed(&self) { + self.status_filter.changed(FilterChange::Different); + self.sorter.changed(SorterChange::Different); + } +} + pub fn compute_unlock_times_ms(count: usize, total_ms: u64, spacing: &str) -> Vec { if count == 0 { return Vec::new(); @@ -97,6 +119,7 @@ pub async fn run_timed_unlock( times_ms: Vec, timed_raw_model: ListStore, cancelled: Arc, + model_updates: AchievementModelUpdates, ) { debug_assert_eq!(achievements.len(), times_ms.len()); if achievements.is_empty() { @@ -152,6 +175,11 @@ pub async fn run_timed_unlock( // Only `Ok(Ok(true))` is Steam saying it stored the thing. match result { Ok(Ok(true)) => { + achievement.set_unlock_time_seconds( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()), + ); batch.record(vec![Change::Achievement { id: achievement.id(), name: achievement.name(), @@ -174,6 +202,7 @@ pub async fn run_timed_unlock( achievement.set_is_achieved(false); } } + model_updates.changed(); next_index += 1; }